Update CI/CD workflows (#66)

**BREAKING CHANGES:**
* Schema change: put user social logins to a `socialLogins` map
* Original users would migrate to new schema without problem (because
sharing the same email)
* Docker images are moved to a new account
[linuxodyssey](https://hub.docker.com/u/linuxodyssey)
* Images are separate to different repo: frontend, backend, analytics,
quest-base
  * Tags are only for versioning.

Major improvements:
* Deploy to production on releasing tags
* Deploy to staging on main branch pushes
* build docker images with versioning tags: `latest`, `main`, `v0.1.0`,
`pr-63`, etc.
* Add basic auth for admin dashboard. Specify username and password by
env.

Minor improvements:
* Upgrade code review bot to use GPT 4
* Centralize all user creation in `models/userManager.js`
* Update docker get image method, based on QUEST_PREFIX to generate the
image name
This commit is contained in:
Lin, Hung-Shin authored and GitHub committed 2023-11-19 15:10:23 +08:00
commit 9bdaa95bad
28 files changed
+364 -178

No files matched your search

+57
View File
@@ -0,0 +1,57 @@
name: Build Docker Image
on:
workflow_call:
inputs:
imageName:
type: string
required: true
description: 'Name of the image to build'
dockerfilePath:
type: string
required: true
description: 'Path to the Dockerfile'
target:
type: string
required: false
description: 'Target to build'
secrets:
DOCKERHUB_USERNAME:
required: true
DOCKERHUB_TOKEN:
required: true
jobs:
build-and-push:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v3
- name: Docker meta
id: meta
uses: docker/metadata-action@v5
with:
images: |
${{ inputs.imageName }}
tags: |
type=ref,event=branch
type=ref,event=pr
type=semver,pattern={{version}}
- name: Login to Docker Hub
uses: docker/login-action@v1
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Build Image
uses: docker/build-push-action@v2
with:
context: .
file: ${{ inputs.dockerfilePath }}
target: ${{ inputs.target }}
push: true
tags: ${{ steps.meta.outputs.tags }}
+36
View File
@@ -0,0 +1,36 @@
name: Build
on:
workflow_call:
jobs:
build-backend:
uses: ./.github/workflows/build-docker-image.yml
with:
imageName: 'linuxodyssey/backend'
dockerfilePath: './server/Dockerfile'
target: prod
secrets: inherit
build-frontend:
uses: ./.github/workflows/build-docker-image.yml
with:
imageName: 'linuxodyssey/frontend'
dockerfilePath: './app/Dockerfile'
target: prod
secrets: inherit
build-analytics:
uses: ./.github/workflows/build-docker-image.yml
with:
imageName: 'linuxodyssey/analytics'
dockerfilePath: './packages/analytics/Dockerfile'
target: prod
secrets: inherit
build-base:
uses: ./.github/workflows/build-docker-image.yml
with:
imageName: 'linuxodyssey/quest-base'
dockerfilePath: './quests/Dockerfile'
secrets: inherit
+45
View File
@@ -0,0 +1,45 @@
name: CI Checks
on:
workflow_call:
jobs:
ci:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v3
- uses: actions/setup-node@v3
with:
node-version: 20
- name: Install dependencies
run: yarn install
- name: Lint
run: yarn lint
- name: Test
run: yarn test
e2e:
runs-on: ubuntu-latest
steps:
- name: Checkout Code
uses: actions/checkout@v3
- name: Build Test Environment, Stage 1
run: docker-compose -f docker-compose.testing.yml build base backend app-test cypress
- name: Build Test Quests
run: docker-compose -f docker-compose.testing.yml run --rm backend yarn build:quests
- name: Run Cypress Tests
env:
GOOGLE_CLIENT_ID: ${{ secrets.GOOGLE_CLIENT_ID }}
GOOGLE_CLIENT_SECRET: ${{ secrets.GOOGLE_CLIENT_SECRET }}
GITHUB_CLIENT_ID: ${{ secrets.GITHUB_CLIENT_ID }}
GITHUB_CLIENT_SECRET: ${{ secrets.GITHUB_CLIENT_SECRET }}
run: docker-compose -f docker-compose.testing.yml run cypress
-110
View File
@@ -1,110 +0,0 @@
name: CI/CD
on:
push:
jobs:
ci:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v3
- uses: actions/setup-node@v3
with:
node-version: 20
- name: Install dependencies
run: yarn install
- name: Lint
run: yarn lint
- name: Test
run: yarn test
e2e:
runs-on: ubuntu-latest
steps:
- name: Checkout Code
uses: actions/checkout@v3
- name: Build Test Environment, Stage 1
run: docker-compose -f docker-compose.testing.yml build base backend app-test
- name: Build Test Quests
run: docker-compose -f docker-compose.testing.yml run --rm backend yarn build:quests
- name: Run Cypress Tests
env:
GOOGLE_CLIENT_ID: ${{ secrets.GOOGLE_CLIENT_ID }}
GOOGLE_CLIENT_SECRET: ${{ secrets.GOOGLE_CLIENT_SECRET }}
GITHUB_CLIENT_ID: ${{ secrets.GITHUB_CLIENT_ID }}
GITHUB_CLIENT_SECRET: ${{ secrets.GITHUB_CLIENT_SECRET }}
run: docker-compose -f docker-compose.testing.yml run cypress
build-and-push:
needs:
- ci
- e2e
if: github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v3
- name: Login to Docker Hub
uses: docker/login-action@v1
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Build backend
uses: docker/build-push-action@v2
with:
context: .
file: ./server/Dockerfile
target: prod
push: true
tags: docker.io/${{ secrets.DOCKERHUB_USERNAME }}/linux-odyssey:backend
- name: Build app
uses: docker/build-push-action@v2
with:
context: .
file: ./app/Dockerfile
target: prod
push: true
tags: docker.io/${{ secrets.DOCKERHUB_USERNAME }}/linux-odyssey:app
- name: Build analytics
uses: docker/build-push-action@v2
with:
context: .
file: ./packages/analytics/Dockerfile
target: prod
push: true
tags: docker.io/${{ secrets.DOCKERHUB_USERNAME }}/linux-odyssey:analytics
- name: Build base image
uses: docker/build-push-action@v2
with:
context: .
file: ./quests/Dockerfile
push: true
tags: docker.io/${{ secrets.DOCKERHUB_USERNAME }}/linux-odyssey:base
deploy:
needs: build-and-push
runs-on: ubuntu-latest
steps:
- name: SSH into server and update service
uses: appleboy/ssh-action@master
with:
host: ${{ secrets.SERVER_HOST }}
port: ${{ secrets.SERVER_PORT }}
username: ${{ secrets.SERVER_USER }}
key: ${{ secrets.SERVER_SSH_KEY }}
+32
View File
@@ -0,0 +1,32 @@
name: Production Deployment
on:
push:
tags:
- 'v*'
concurrency:
group: production
cancel-in-progress: true
jobs:
ci-checks:
uses: ./.github/workflows/ci-checks.yml
build:
needs: ci-checks
uses: ./.github/workflows/build.yml
secrets: inherit
deploy:
needs: build
runs-on: ubuntu-latest
steps:
- name: SSH into server and update service
uses: appleboy/ssh-action@master
with:
host: ${{ secrets.GCP_SSH_HOST }}
port: ${{ secrets.GCP_SSH_PORT }}
username: ${{ secrets.GCP_SSH_USER }}
key: ${{ secrets.GCP_SSH_PRIVATE_KEY }}
@@ -1,11 +1,17 @@
name: Code Review GPT
name: Pull Request Workflow
on:
pull_request:
branches: [main]
concurrency:
group: ${{ github.ref }}
cancel-in-progress: true
jobs:
run_code_review:
ci-checks:
uses: ./.github/workflows/ci-checks.yml
code_review:
permissions:
pull-requests: write
runs-on: ubuntu-latest
@@ -18,5 +24,10 @@ jobs:
uses: mattzcarey/code-review-gpt@v0.1.4-alpha
with:
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
MODEL: 'gpt-3.5-turbo'
MODEL: 'gpt-4'
GITHUB_TOKEN: ${{ github.token }}
build:
needs: ci-checks
uses: ./.github/workflows/build.yml
secrets: inherit
+32
View File
@@ -0,0 +1,32 @@
name: Staging Deployment
on:
push:
branches:
- main
concurrency:
group: staging
cancel-in-progress: true
jobs:
ci-checks:
uses: ./.github/workflows/ci-checks.yml
build:
needs: ci-checks
uses: ./.github/workflows/build.yml
secrets: inherit
deploy:
needs: build
runs-on: ubuntu-latest
steps:
- name: SSH into server and update service
uses: appleboy/ssh-action@master
with:
host: ${{ secrets.SERVER_HOST }}
port: ${{ secrets.SERVER_PORT }}
username: ${{ secrets.SERVER_USER }}
key: ${{ secrets.SERVER_SSH_KEY }}
+7 -4
View File
@@ -1,7 +1,7 @@
version: '3'
services:
app:
image: lancatlin/linux-odyssey:app
image: linuxodyssey/frontend
depends_on:
- backend
restart: always
@@ -13,8 +13,9 @@ services:
internal:
ports:
- 8080:80
backend:
image: lancatlin/linux-odyssey:backend
image: linuxodyssey/backend
depends_on:
- db
restart: always
@@ -36,7 +37,7 @@ services:
- TRUSTED_PROXIES=${TRUSTED_PROXIES}
analytics:
image: lancatlin/linux-odyssey:analytics
image: linuxodyssey/analytics
build:
context: .
dockerfile: packages/analytics/Dockerfile
@@ -50,6 +51,8 @@ services:
- '3001:3001'
environment:
- MONGO_URL=mongodb://${MONGO_USER}:${MONGO_PASSWORD}@db:27017/${MONGO_DB}
- ADMIN_USERNAME=${ADMIN_USERNAME}
- ADMIN_PASSWORD=${ADMIN_PASSWORD}
db:
image: mongo
@@ -76,7 +79,7 @@ services:
- BUG_REPORT_URL=${BUG_REPORT_URL}
base:
image: lancatlin/linux-odyssey:base
image: linuxodyssey/quest-base
build:
context: .
dockerfile: ./quests/Dockerfile
+5 -3
View File
@@ -16,7 +16,7 @@ services:
- app-test
app-test:
image: lancatlin/linux-odyssey:app
image: linuxodyssey/app
build:
context: .
dockerfile: app/Dockerfile
@@ -29,7 +29,7 @@ services:
- backend
backend:
image: lancatlin/linux-odyssey:backend
image: linuxodyssey/backend
build:
context: .
dockerfile: server/Dockerfile
@@ -69,9 +69,11 @@ services:
- MONGO_USER=user
- MONGO_PASS=user-password
- MONGO_DB=odyssey-test
ports:
- 27017:27017
base:
image: lancatlin/linux-odyssey:base
image: linuxodyssey/quest-base
build:
context: .
dockerfile: ./quests/Dockerfile
+4 -1
View File
@@ -14,6 +14,7 @@ services:
volumes:
- ./app:/app/app
- ./packages:/app/packages
backend:
depends_on:
- db
@@ -62,6 +63,8 @@ services:
- '3001:3001'
environment:
- MONGO_URL=mongodb://db:27017/odyssey-test
- USERNAME=user
- PASSWORD=user
volumes:
- ./packages/analytics:/app/packages/analytics
- ./packages/models:/app/packages/models
@@ -85,7 +88,7 @@ services:
- SWAGGER_FILE=/tmp/swagger.yaml
base:
image: lancatlin/linux-odyssey:base
image: linuxodyssey/quest-base
build:
context: .
dockerfile: ./quests/Dockerfile
+1
View File
@@ -12,6 +12,7 @@
"dependencies": {
"@linux-odyssey/models": "*",
"express": "^4.18.2",
"express-basic-auth": "^1.2.1",
"express-handlebars": "^7.1.2"
},
"devDependencies": {
+9
View File
@@ -1,7 +1,16 @@
function getOrFailed(key) {
if (!process.env[key]) {
throw new Error(`Missing required environment variable: ${key}`)
}
return process.env[key]
}
const config = {
host: process.env.HOST || 'localhost',
port: process.env.PORT || 3001,
db: process.env.MONGO_URL || 'mongodb://localhost:27017/odyssey-test',
username: getOrFailed('ADMIN_USERNAME'),
password: getOrFailed('ADMIN_PASSWORD'),
}
config.baseUrl = process.env.BASE_URL || `http://${config.host}:${config.port}`
+8
View File
@@ -1,6 +1,7 @@
import connectDB from '@linux-odyssey/models'
import express from 'express'
import { engine } from 'express-handlebars'
import basicAuth from 'express-basic-auth'
import config from './config.js'
import router from './router.js'
@@ -13,6 +14,13 @@ async function main() {
app.set('view engine', 'handlebars')
app.set('views', './views')
app.use(
basicAuth({
users: { [config.username]: config.password },
challenge: true,
realm: 'Linux Odyssey Admin Dashboard',
})
)
app.use(router)
app.use(express.static('./public'))
+1 -2
View File
@@ -3,8 +3,7 @@ import { Session, User } from '@linux-odyssey/models'
function loginMethods(user) {
const methods = []
if (user.hashedPassword) methods.push('password')
if (user.google?.id) methods.push('google')
if (user.github?.id) methods.push('github')
if (user.socialLogins) methods.push(...Object.keys(user.socialLogins))
return methods
}
+20 -7
View File
@@ -1,5 +1,20 @@
import { model, Schema } from 'mongoose'
const socialLoginSchema = new Schema({
provider: {
type: String,
required: true,
},
id: {
type: String,
required: true,
},
displayName: {
type: String,
required: false,
},
})
const User = model(
'User',
new Schema(
@@ -15,13 +30,11 @@ const User = model(
required: true,
},
hashedPassword: String,
google: {
id: String,
displayName: String,
},
github: {
id: String,
displayName: String,
socialLogins: {
type: Map,
of: socialLoginSchema,
default: {},
required: true,
},
},
{
+1 -1
View File
@@ -1,3 +1,3 @@
FROM lancatlin/linux-odyssey:base
FROM linuxodyssey/quest-base
COPY home ${HOME}
+1 -1
View File
@@ -1,3 +1,3 @@
FROM lancatlin/linux-odyssey:base
FROM linuxodyssey/quest-base
COPY home ${HOME}
+8 -6
View File
@@ -1,5 +1,4 @@
import { matchedData } from 'express-validator'
import { User } from '@linux-odyssey/models'
import { genJWT } from '../../utils/auth.js'
import { createUser } from '../../models/userManager.js'
import { asyncHandler } from '../../middleware/error.js'
@@ -17,7 +16,7 @@ export const issueToken = asyncHandler(async (req, res) => {
export const register = asyncHandler(async (req, res, next) => {
const { username, password, email } = matchedData(req)
const user = await createUser(username, email, password)
const user = await createUser(username, email, { password })
req.login(user, (err) => {
if (err) {
next(err)
@@ -50,7 +49,7 @@ export function checkSession(req, res) {
})
}
export function socialLogin(req, res) {
export function socialLoginHandler(req, res) {
const { newUser } = req.user
if (newUser) {
req.session.newUser = newUser
@@ -71,10 +70,13 @@ export const registerFromSession = asyncHandler(async (req, res, next) => {
return
}
newUser.username = username
const user = new User(newUser)
const { email, socialLogin } = newUser
await user.save()
if (!socialLogin) {
throw new Error('Socail Login not found')
}
const user = await createUser(username, email, { socialLogin })
req.login(user, (err) => {
if (err) {
+3 -3
View File
@@ -7,8 +7,8 @@ import {
register,
checkSession,
logout,
socialLogin,
registerFromSession,
socialLoginHandler,
} from '../controllers/authController.js'
import {
checkUsernameValidators,
@@ -49,7 +49,7 @@ if (enabledMethods.google) {
'/google/callback',
authenticateRateLimit,
passport.authenticate('google'),
socialLogin
socialLoginHandler
)
}
@@ -59,7 +59,7 @@ if (enabledMethods.github) {
'/github/callback',
authenticateRateLimit,
passport.authenticate('github'),
socialLogin
socialLoginHandler
)
}
+1 -1
View File
@@ -4,7 +4,7 @@ import config from '../config.js'
import oauthVerify from './oauthVerify.js'
function verify(accessToken, refreshToken, profile, cb) {
oauthVerify('github', profile, { 'github.id': profile.id }, cb)
oauthVerify('github', profile, { 'socialLogins.github.id': profile.id }, cb)
}
const enabled = config.github.clientID && config.github.clientSecret
+1 -1
View File
@@ -4,7 +4,7 @@ import config from '../config.js'
import oauthVerify from './oauthVerify.js'
function verify(issuer, profile, cb) {
oauthVerify('google', profile, { 'google.id': profile.id }, cb)
oauthVerify('google', profile, { 'socialLogins.google.id': profile.id }, cb)
}
const enabled = config.google.clientID && config.google.clientSecret
+21 -16
View File
@@ -2,26 +2,31 @@ import { User } from '@linux-odyssey/models'
export default async function oauthVerify(provider, profile, query, cb) {
process.nextTick(async () => {
const email = profile.emails[0]?.value
if (!email) {
return cb(new Error('No email found'))
}
let user = await User.findOne(query)
if (!user) {
user = await User.findOne({ email })
if (!user) {
const newUser = { email }
newUser[provider] = profile
return cb(null, { newUser })
try {
const email = profile.emails[0]?.value
if (!email) {
return cb(new Error('No email found'))
}
user[provider] = {
const socialLogin = {
provider,
id: profile.id,
displayName: profile.displayName,
}
await user.save()
}
let user = await User.findOne(query)
if (!user) {
user = await User.findOne({ email })
if (!user) {
const newUser = { email, socialLogin }
return cb(null, { newUser })
}
user.socialLogins.set(provider, socialLogin)
await user.save()
}
return cb(null, user)
return cb(null, user)
} catch (err) {
return cb(err)
}
})
}
+11 -7
View File
@@ -26,14 +26,18 @@ if (enabledMethods.github) {
passport.serializeUser((user, done) => {
process.nextTick(async () => {
if (!(await UserProfile.exists({ user: user._id }))) {
UserProfile.create({ user: user._id })
try {
if (user._id && !(await UserProfile.exists({ user: user._id }))) {
await UserProfile.create({ user: user._id })
}
done(null, {
_id: user._id,
username: user.username,
email: user.email,
})
} catch (err) {
done(err)
}
done(null, {
_id: user._id,
username: user.username,
email: user.email,
})
})
})
+10 -3
View File
@@ -24,7 +24,6 @@ const config = {
port: get('PORT', 3000),
db: get('MONGO_URL', 'mongodb://localhost:27017/odyssey-test'),
secret: get('SECRET_KEY', ''),
dockerNetwork: get('DOCKER_NETWORK', 'linux-odyssey-players'),
isProduction: process.env.NODE_ENV === 'production',
hostPwd: get('HOST_PWD', ''),
containerExpiry: get('EXPIRY', 1000 * 60 * 60),
@@ -40,9 +39,13 @@ const config = {
trustedProxies: getTrustProxies('TRUSTED_PROXIES'),
surveyUrl: getUrl('SURVEY_URL', ''),
bugReportUrl: getUrl('BUG_REPORT_URL', ''),
dockerImage: get('DOCKER_IMAGE', 'lancatlin/linux-odyssey'),
mountQuest: get('MOUNT_QUEST', ''),
docker: {
network: get('DOCKER_NETWORK', 'linux-odyssey-players'),
defaultImage: get('QUEST_IMAGE', 'linuxodyssey/quest-base'),
imagePrefix: get('DOCKER_PREFIX', 'linuxodyssey/quest-'),
mountQuest: get('MOUNT_QUEST', ''),
},
}
config.baseUrl = get('BASE_URL', `http://${config.host}:${config.port}`)
@@ -52,4 +55,8 @@ config.testing = {
password: get('TESTING_PASSWORD', ''),
}
export function getQuestImage(id) {
return `${config.docker.imagePrefix}${id}`
}
export default config
+5 -5
View File
@@ -1,5 +1,5 @@
import Docker from 'dockerode'
import config from '../config.js'
import config, { getQuestImage } from '../config.js'
const engine = new Docker()
@@ -11,17 +11,17 @@ const containerOptions = {
OpenStdin: true,
StdinOnce: false,
HostConfig: {
NetworkMode: config.dockerNetwork,
NetworkMode: config.docker.network,
},
}
// const network = engine.getNetwork(config.dockerNetwork)
export function createContainer(name, tag) {
export function createContainer(name, questId) {
const option = {
...containerOptions,
name,
Image: `${config.dockerImage}:${tag}`,
Image: getQuestImage(questId),
}
if (!config.isProduction && config.hostPwd && config.mountQuest) {
option.HostConfig.Bind = [
@@ -87,7 +87,7 @@ export function buildQuestImage(questPath, questId) {
src: ['Dockerfile', 'home'],
},
{
t: `${config.dockerImage}:${questId}`,
t: getQuestImage(questId),
networkmode: 'none',
memory: 10 * 1e6,
},
+10 -2
View File
@@ -1,16 +1,24 @@
import { User, UserProfile } from '@linux-odyssey/models'
import { hashPassword } from '../utils/auth.js'
export async function createUser(username, email, password) {
export async function createUser(username, email, { password, socialLogin }) {
const user = new User({
username,
email,
})
user.hashedPassword = await hashPassword(password)
if (password) {
user.hashedPassword = await hashPassword(password)
} else if (socialLogin) {
user.socialLogins.set(socialLogin.provider, socialLogin)
} else {
throw new Error('No password or social login provided')
}
await user.save()
const userProfile = new UserProfile({
user,
})
await userProfile.save()
return user
}
+1 -1
View File
@@ -17,6 +17,6 @@ export default async function setupTest() {
return
}
await createUser(username, email, password)
await createUser(username, email, { password })
console.log('Created testing account')
}
+19
View File
@@ -1593,6 +1593,13 @@ base64url@3.x.x:
resolved "https://registry.yarnpkg.com/base64url/-/base64url-3.0.1.tgz#6399d572e2bc3f90a9a8b22d5dbb0a32d33f788d"
integrity sha512-ir1UPr3dkwexU7FdV8qBBbNDRUhMmIekYMFZfi+C/sLNnRESKPl23nB9b2pltqfOQNnGzsDdId90AEtG5tCx4A==
basic-auth@^2.0.1:
version "2.0.1"
resolved "https://registry.yarnpkg.com/basic-auth/-/basic-auth-2.0.1.tgz#b998279bf47ce38344b4f3cf916d4679bbf51e3a"
integrity sha512-NF+epuEdnUYVlGuhaxbbq+dvJttwLnGY+YixlXlME5KpQ5W3CnXA5cVTneY3SPbPDRkcjMbifrwmFYcClgOZeg==
dependencies:
safe-buffer "5.1.2"
bcrypt-pbkdf@^1.0.0, bcrypt-pbkdf@^1.0.2:
version "1.0.2"
resolved "https://registry.yarnpkg.com/bcrypt-pbkdf/-/bcrypt-pbkdf-1.0.2.tgz#a4301d389b6a43f9b67ff3ca11a3f6637e360e9e"
@@ -2850,6 +2857,13 @@ expect@^29.6.1:
jest-message-util "^29.6.1"
jest-util "^29.6.1"
express-basic-auth@^1.2.1:
version "1.2.1"
resolved "https://registry.yarnpkg.com/express-basic-auth/-/express-basic-auth-1.2.1.tgz#d31241c03a915dd55db7e5285573049cfcc36381"
integrity sha512-L6YQ1wQ/mNjVLAmK3AG1RK6VkokA1BIY6wmiH304Xtt/cLTps40EusZsU1Uop+v9lTDPxdtzbFmdXfFO3KEnwA==
dependencies:
basic-auth "^2.0.1"
express-handlebars@^7.1.2:
version "7.1.2"
resolved "https://registry.yarnpkg.com/express-handlebars/-/express-handlebars-7.1.2.tgz#2471673d11af46f496cba4098a705f0217232fda"
@@ -5581,6 +5595,11 @@ rxjs@^7.5.1:
dependencies:
tslib "^2.1.0"
safe-buffer@5.1.2:
version "5.1.2"
resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.1.2.tgz#991ec69d296e0313747d59bdfd2b745c35f8828d"
integrity sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==
safe-buffer@5.2.1, safe-buffer@^5.0.1, safe-buffer@^5.1.2, safe-buffer@~5.2.0:
version "5.2.1"
resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.2.1.tgz#1eaf9fa9bdb1fdd4ec75f58f9cdb4e6b7827eec6"