diff --git a/.buildkite/discord.sh b/.buildkite/discord.sh deleted file mode 100644 index 77540539..00000000 --- a/.buildkite/discord.sh +++ /dev/null @@ -1,14 +0,0 @@ -STAGE=$BUILDKITE_BRANCH -if [ "$STAGE" = 'main' ]; then - STAGE='production' -fi - -if [ "$STAGE" != 'production' ] && [ "$STAGE" != 'staging' ]; then - echo 'Stage '$STAGE' unknown, skipping Discord' - exit 0 -fi - -curl -sSf -H 'Content-Type: application/json' \ - --request POST \ - --data '{"content": "Deploy for '"$BUILDKITE_PIPELINE_SLUG"' '$STAGE' is finished"}' \ - "$DISCORD_WEBHOOK_URL" diff --git a/.buildkite/pipeline.yml b/.buildkite/pipeline.yml deleted file mode 100644 index 67b0498a..00000000 --- a/.buildkite/pipeline.yml +++ /dev/null @@ -1,70 +0,0 @@ -steps: - - label: ":docker: Build test image" - plugins: - - docker-login#v2.0.1: - server: docker.guidojw.nl - username: buildkite - password-env: DOCKER_PASSWORD - - docker-compose#v3.7.0: - build: app - config: docker-compose.buildkite.yml - image-repository: docker.guidojw.nl/arora/arora-discord - - - wait - - - label: ":yarn: :eslint: Lint" - command: yarn lint - plugins: - - docker-compose#v3.7.0: - run: app - config: docker-compose.buildkite.yml - - - label: ":yarn: Test" - command: yarn test - plugins: - - docker-compose#v3.7.0: - run: app - config: docker-compose.buildkite.yml - - - wait - - - label: ":docker: Build+Push `:staging` image" - branches: staging - plugins: - - docker-login#v2.0.1: - server: docker.guidojw.nl - username: buildkite - password-env: DOCKER_PASSWORD - - docker-compose#v3.7.0: - push: app-staging:docker.guidojw.nl/arora/arora-discord:staging - image-name: docker.guidojw.nl/arora/arora-discord:staging - config: docker-compose.buildkite.yml - - - label: ":docker: Build+Push `:production` image" - branches: main - plugins: - - docker-login#v2.0.1: - server: docker.guidojw.nl - username: buildkite - password-env: DOCKER_PASSWORD - - docker-compose#v3.7.0: - push: app-production:docker.guidojw.nl/arora/arora-discord:latest - image-name: docker.guidojw.nl/arora/arora-discord:latest - config: docker-compose.buildkite.yml - - - block: Deploy this stage - branches: main staging - - - label: Restarting service - command: .buildkite/release.sh - branches: main staging - - - wait - - - label: Inform Sentry - command: .buildkite/sentry.sh - branches: main staging - - - label: Inform Discord - command: .buildkite/discord.sh - branches: main staging diff --git a/.buildkite/release.sh b/.buildkite/release.sh deleted file mode 100644 index 35de9db8..00000000 --- a/.buildkite/release.sh +++ /dev/null @@ -1,15 +0,0 @@ -STAGE=$BUILDKITE_BRANCH -if [ "$STAGE" = 'main' ]; then - STAGE='production' -fi - -if [ "$STAGE" != 'production' ] && [ "$STAGE" != 'staging' ]; then - echo 'Stage '$STAGE' unknown, skipping deploy' - exit 0 -fi - -cd /opt/docker/arora-discord/$STAGE || exit -docker-compose pull app -docker-compose build app -docker-compose run --rm app npx typeorm migration:run -docker-compose up -d app diff --git a/.buildkite/sentry.sh b/.buildkite/sentry.sh deleted file mode 100644 index 0bb39bab..00000000 --- a/.buildkite/sentry.sh +++ /dev/null @@ -1,15 +0,0 @@ -STAGE=$BUILDKITE_BRANCH -if [ "$STAGE" = 'main' ]; then - STAGE='production' -fi - -if [ "$STAGE" != 'production' ] && [ "$STAGE" != 'staging' ]; then - echo 'Stage '$STAGE' unknown, skipping Sentry' - exit 0 -fi - -curl -sSf -H 'Content-Type: application/json' \ - -H 'Authorization: Bearer '"$SENTRY_API_TOKEN" \ - --request POST \ - --data '{"version": "'"$BUILDKITE_COMMIT"'"}' \ - https://sentry.io/api/0/projects/guidos-projects/arora-discord/releases/ diff --git a/.github/problem-matchers/eslint-stylish.json b/.github/problem-matchers/eslint-stylish.json new file mode 100644 index 00000000..cc2067d0 --- /dev/null +++ b/.github/problem-matchers/eslint-stylish.json @@ -0,0 +1,22 @@ +{ + "problemMatcher": [ + { + "owner": "eslint-stylish", + "pattern": [ + { + "regexp": "^/app/([^\\s].*)$", + "file": 1 + }, + { + "regexp": "^\\s+(\\d+):(\\d+)\\s+(error|warning|info)\\s+(.*)\\s\\s+(.*)$", + "line": 1, + "column": 2, + "severity": 3, + "message": 4, + "code": 5, + "loop": true + } + ] + } + ] +} diff --git a/.github/problem-matchers/tsc.json b/.github/problem-matchers/tsc.json new file mode 100644 index 00000000..22e7fade --- /dev/null +++ b/.github/problem-matchers/tsc.json @@ -0,0 +1,18 @@ +{ + "problemMatcher": [ + { + "owner": "tsc", + "pattern": [ + { + "regexp": "^([^\\s].*)[\\(:](\\d+)[,:](\\d+)(?:\\):\\s+|\\s+-\\s+)(error|warning|info)\\s+TS(\\d+)\\s*:\\s*(.*)$", + "file": 1, + "line": 2, + "column": 3, + "severity": 4, + "code": 5, + "message": 6 + } + ] + } + ] +} diff --git a/.github/renovate.json b/.github/renovate.json index d51977b4..5039b0f2 100644 --- a/.github/renovate.json +++ b/.github/renovate.json @@ -1,5 +1,5 @@ { - "extends": ["config:base", ":preserveSemverRanges", ":disableDependencyDashboard"], + "extends": ["config:base", ":preserveSemverRanges", ":disableDependencyDashboard", "helpers:pinGitHubActionDigests"], "labels": ["dependencies"], "rangeStrategy": "bump", "packageRules": [ @@ -27,11 +27,11 @@ "matchUpdateTypes": ["minor", "patch"], "groupName": "Node.js", "groupSlug": "nodejs" + }, + { + "matchManagers": ["github-actions"] } ], - "buildkite": { - "enabled": false - }, "docker-compose": { "enabled": false } diff --git a/.github/workflows/cleanup-registry.yml b/.github/workflows/cleanup-registry.yml new file mode 100644 index 00000000..5da1e654 --- /dev/null +++ b/.github/workflows/cleanup-registry.yml @@ -0,0 +1,23 @@ +name: Cleanup Registry + +on: + schedule: + - cron: '0 0 * * 1' # https://crontab.guru/#0_0_*_*_1 + workflow_dispatch: + +env: + IMAGE_NAMES: arora-discord + +jobs: + cleanup: + name: Cleanup + runs-on: ubuntu-latest + steps: + - name: Delete old versions + uses: snok/container-retention-policy@455daa39a1817585ad2ef264650d3ff84b1c81d9 # renovate: tag=v1.2.1 + with: + image-names: ${{ env.IMAGE_NAMES }} + cut-off: 2 days ago UTC + account-type: personal + skip-tags: latest,staging + token: ${{ secrets.PAT }} diff --git a/.github/workflows/continuous-delivery.yml b/.github/workflows/continuous-delivery.yml new file mode 100644 index 00000000..b8e5cd36 --- /dev/null +++ b/.github/workflows/continuous-delivery.yml @@ -0,0 +1,201 @@ +name: Continuous Delivery + +on: + workflow_dispatch: + inputs: + merge: + description: Merge staging into main first? (y/N) + required: false + default: 'n' + +concurrency: + group: cd-${{ github.ref_name }} + +env: + PROJECT_NAME: arora-discord + SENTRY_ORG: guidos-projects + APP_ID: 152323 + +jobs: + branch_check: + name: Branch Check + runs-on: ubuntu-latest + steps: + - name: Validate branch + run: | + if [ $GITHUB_REF_NAME != 'staging' ] && [ $GITHUB_REF_NAME != 'main' ]; then + echo 'This workflow can only be run on branches staging and main.' + exit 1 + fi + + metadata: + name: Metadata + runs-on: ubuntu-latest + needs: branch_check + outputs: + has_diff: ${{ steps.get_metadata.outputs.has_diff }} + stage: ${{ steps.get_metadata.outputs.stage }} + steps: + - name: Checkout code + uses: actions/checkout@ec3a7ce113134d7a93b817d10a8272cb61118579 # renovate: tag=v2.4.0 + + - name: Get metadata + id: get_metadata + env: + INPUT_MERGE: ${{ github.event.inputs.merge }} + run: | + if [ $GITHUB_REF_NAME = 'main' ]; then + if [ "${INPUT_MERGE,,}" = 'y' ]; then + git fetch origin staging + if ! git diff origin/main origin/staging --exit-code; then + echo '::set-output name=has_diff::true' + else + echo '::set-output name=has_diff::false' + fi + fi + + echo '::set-output name=stage::production' + else + echo '::set-output name=stage::staging' + fi + + merge: + name: Merge + runs-on: ubuntu-latest + needs: metadata + if: github.event.inputs.merge == 'y' + outputs: + sha: ${{ steps.get_sha.outputs.sha }} + steps: + - name: Validate inputs + env: + HAS_DIFF: ${{ fromJSON(needs.metadata.outputs.has_diff || false) }} + run: | + if [ $GITHUB_REF_NAME != 'main' ]; then + echo 'Can only merge when the workflow target branch is main.' + exit 1 + fi + + if ! $HAS_DIFF; then + echo 'There is no diff so a merge is not necessary, skipping next steps.' + fi + + - name: Checkout code + if: fromJSON(needs.metadata.outputs.has_diff) + uses: actions/checkout@ec3a7ce113134d7a93b817d10a8272cb61118579 # renovate: tag=v2.4.0 + + - name: Run merge + if: fromJSON(needs.metadata.outputs.has_diff) + uses: devmasx/merge-branch@854d3ac71ed1e9deb668e0074781b81fdd6e771f # renovate: tag=v1.4.0 + with: + type: now + from_branch: staging + target_branch: main + github_token: ${{ github.token }} + + - name: Get merge commit SHA + id: get_sha + if: fromJSON(needs.metadata.outputs.has_diff) + run: | + git fetch origin main + echo '::set-output name=sha::'$(git rev-parse origin/main) + + continuous_integration: + name: Continuous Integration + needs: [metadata, merge] + if: fromJSON(needs.metadata.outputs.has_diff) + uses: guidojw/arora-discord/.github/workflows/continuous-integration.yml@staging + with: + sha: ${{ needs.merge.outputs.sha }} + + publish_image: + name: Publish Image + needs: [metadata, merge] + if: fromJSON(needs.metadata.outputs.has_diff) + uses: guidojw/arora-discord/.github/workflows/publish-image.yml@staging + with: + sha: ${{ needs.merge.outputs.sha }} + + deploy: + name: Deploy + runs-on: ubuntu-latest + needs: [metadata, merge, continuous_integration, publish_image] + if: | + (github.ref_name == 'staging' || github.ref_name == 'main') && ((github.ref_name == 'main' && + github.event.inputs.merge == 'y' && fromJSON(needs.metadata.outputs.has_diff) && success()) || + ((github.event.inputs.merge != 'y' || !fromJSON(needs.metadata.outputs.has_diff)) && !cancelled())) + steps: + - name: Checkout code + uses: actions/checkout@ec3a7ce113134d7a93b817d10a8272cb61118579 # renovate: tag=v2.4.0 + with: + ref: ${{ needs.merge.outputs.sha }} + + - name: Start deployment + uses: bobheadxi/deployments@3288e49ca0bde112b9414f998339321d537e2a51 # renovate: tag=v0.6.1 + id: start_deployment + with: + step: start + token: ${{ secrets.GITHUB_TOKEN }} + env: ${{ needs.metadata.outputs.stage }} + + - name: Deploy + uses: appleboy/ssh-action@1d1b21ca96111b1eb4c03c21c14ebb971d2200f6 # renovate: tag=v0.1.4 + env: + STAGE: ${{ needs.metadata.outputs.stage }} + with: + host: guidojw.nl + username: github-actions + key: ${{ secrets.SSH_PRIVATE_KEY }} + envs: PROJECT_NAME,STAGE + script: | + cd /opt/docker/$PROJECT_NAME/$STAGE + docker-compose pull + docker-compose run --rm app yarn run typeorm migration:run + docker-compose up -d + + - name: Finalize Sentry release + uses: getsentry/action-release@744e4b262278339b79fb39c8922efcae71e98e39 # renovate: tag=v1.1.6 + env: + SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} + SENTRY_PROJECT: ${{ env.PROJECT_NAME }} + with: + environment: ${{ needs.metadata.outputs.stage }} + set_commits: skip + + - name: Finish deployment + uses: bobheadxi/deployments@3288e49ca0bde112b9414f998339321d537e2a51 # renovate: tag=v0.6.1 + if: steps.start_deployment.conclusion == 'success' && always() + with: + step: finish + token: ${{ secrets.GITHUB_TOKEN }} + status: ${{ job.status }} + deployment_id: ${{ steps.start_deployment.outputs.deployment_id }} + + update_check_run: + name: Update Check Run + runs-on: ubuntu-latest + needs: [branch_check, metadata, merge, continuous_integration, publish_image, deploy] + if: (github.ref_name == 'staging' || github.ref_name == 'main') && always() + steps: + - name: Get conclusion + id: get_conclusion + env: + RESULTS: ${{ join(needs.*.result, ' ') }} + run: | + echo '::set-output name=conclusion::success' + for RESULT in $RESULTS; do + if [ $RESULT = 'cancelled' ] || [ $RESULT = 'failure' ]; then + echo '::set-output name=conclusion::'$RESULT + break + fi + done + + - name: Update Continuous Delivery check run + uses: guidojw/actions/update-check-run@fcb92ed90a3965b130f4e5ed3f53df9ef65e6de1 # renovate: tag=v1 + with: + app_id: ${{ env.APP_ID }} + private_key: ${{ secrets.APP_PRIVATE_KEY }} + sha: ${{ needs.merge.outputs.sha }} + name: Continuous Delivery + conclusion: ${{ steps.get_conclusion.outputs.conclusion }} + details_url: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} diff --git a/.github/workflows/continuous-integration.yml b/.github/workflows/continuous-integration.yml new file mode 100644 index 00000000..65ab8b10 --- /dev/null +++ b/.github/workflows/continuous-integration.yml @@ -0,0 +1,53 @@ +name: Continuous Integration + +on: + push: + branches: [staging, main] + pull_request: + workflow_call: + inputs: + sha: + required: false + type: string + +jobs: + build: + name: Build + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@ec3a7ce113134d7a93b817d10a8272cb61118579 # renovate: tag=v2.4.0 + with: + ref: ${{ inputs.sha }} + + - name: Add problem matchers + run: | + echo '::add-matcher::.github/problem-matchers/tsc.json' + + - name: Build test image + uses: guidojw/actions/build-docker-image@fcb92ed90a3965b130f4e5ed3f53df9ef65e6de1 # renovate: tag=v1.0.0 + with: + name: app + + lint: + name: Lint + runs-on: ubuntu-latest + needs: build + steps: + - name: Checkout code + uses: actions/checkout@ec3a7ce113134d7a93b817d10a8272cb61118579 # renovate: tag=v2.4.0 + with: + ref: ${{ inputs.sha }} + + - name: Add problem matchers + run: | + echo '::add-matcher::.github/problem-matchers/eslint-stylish.json' + + - name: Load test image + uses: guidojw/actions/load-docker-image@fcb92ed90a3965b130f4e5ed3f53df9ef65e6de1 # renovate: tag=v1.0.0 + with: + name: app + + - name: Lint + run: | + docker run app yarn lint diff --git a/.github/workflows/publish-image.yml b/.github/workflows/publish-image.yml new file mode 100644 index 00000000..245fb46b --- /dev/null +++ b/.github/workflows/publish-image.yml @@ -0,0 +1,129 @@ +name: Publish Image + +on: + push: + branches: [staging, main] + workflow_call: + inputs: + sha: + required: false + type: string + workflow_dispatch: + +env: + PROJECT_NAME: arora-discord + REGISTRY_URL: ghcr.io + SENTRY_ORG: guidos-projects + APP_ID: 152323 + +jobs: + metadata: + name: Metadata + runs-on: ubuntu-latest + outputs: + tag: ${{ steps.get_metadata.outputs.tag }} + build_args: ${{ steps.get_metadata.outputs.build_args }} + steps: + - name: Get metadata + id: get_metadata + run: | + if [ $GITHUB_REF_NAME = 'main' ]; then + echo '::set-output name=tag::latest' + else + echo '::set-output name=tag::'$GITHUB_REF_NAME + fi + + if [ $GITHUB_REF_NAME = 'staging' ] || [ $GITHUB_REF_NAME = 'main' ]; then + BUILD_ARGS='BUILD_HASH='$GITHUB_SHA + if [ $GITHUB_REF_NAME = 'main' ]; then + BUILD_ARGS+=$'\nNODE_ENV=production' + + echo '::set-output name=stage::production' + else + BUILD_ARGS+=$'\nNODE_ENV=staging' + + echo '::set-output name=stage::staging' + fi + BUILD_ARGS=${BUILD_ARGS//$'\n'/'%0A'} + echo '::set-output name=build_args::'$BUILD_ARGS + fi + + publish: + name: Publish + runs-on: ubuntu-latest + needs: metadata + steps: + - name: Checkout code + uses: actions/checkout@ec3a7ce113134d7a93b817d10a8272cb61118579 # renovate: tag=v2.4.0 + with: + ref: ${{ inputs.sha }} + fetch-depth: 0 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@94ab11c41e45d028884a99163086648e898eed25 # renovate: tag=v1.6.0 + + - name: Login to GitHub Container Registry + uses: docker/login-action@f054a8b539a109f9f41c372932f1ae047eff08c9 # renovate: tag=v1.10.0 + with: + registry: ${{ env.REGISTRY_URL }} + username: ${{ github.repository_owner }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Build and push image + id: build_push_image + uses: docker/build-push-action@a66e35b9cbcf4ad0ea91ffcaf7bbad63ad9e0229 # renovate: tag=v2.7.0 + with: + push: true + context: . + build-args: ${{ needs.metadata.outputs.build_args }} + cache-from: type=gha,scope=main + cache-to: type=gha,scope=main + tags: | + ${{ env.REGISTRY_URL }}/${{ github.repository_owner }}/${{ env.PROJECT_NAME }}:${{ + needs.metadata.outputs.tag }} + + - name: Get sourcemaps from image + if: ${{ !(github.event_name == 'workflow_dispatch' && github.workflow == 'Publish Image') }} + env: + IMAGE_NAME: ${{ fromJSON(steps.build_push_image.outputs.metadata)['image.name'] }} + run: | + docker cp $(docker create $IMAGE_NAME):/opt/app/dist/. sourcemaps + + - name: Create Sentry release + if: ${{ !(github.event_name == 'workflow_dispatch' && github.workflow == 'Publish Image') }} + uses: getsentry/action-release@744e4b262278339b79fb39c8922efcae71e98e39 # renovate: tag=v1.1.6 + env: + SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} + SENTRY_PROJECT: ${{ env.PROJECT_NAME }} + with: + environment: ${{ needs.metadata.outputs.stage }} + finalize: false + sourcemaps: sourcemaps + + update_check_run: + name: Update Check Run + runs-on: ubuntu-latest + needs: [metadata, publish] + if: github.event_name == 'workflow_dispatch' && github.workflow == 'Publish Image' && always() + steps: + - name: Get conclusion + id: get_conclusion + env: + RESULTS: ${{ join(needs.*.result, ' ') }} + run: | + echo '::set-output name=conclusion::success' + for RESULT in $RESULTS; do + if [ $RESULT = 'cancelled' ] || [ $RESULT = 'failure' ]; then + echo '::set-output name=conclusion::'$RESULT + break + fi + done + + - name: Update Publish Image check run + uses: guidojw/actions/update-check-run@fcb92ed90a3965b130f4e5ed3f53df9ef65e6de1 # renovate: tag=v1.0.0 + with: + app_id: ${{ env.APP_ID }} + private_key: ${{ secrets.APP_PRIVATE_KEY }} + name: Publish Image + conclusion: ${{ steps.get_conclusion.outputs.conclusion }} + details_url: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} diff --git a/Dockerfile b/Dockerfile index 1b3079c7..9b54bd19 100644 --- a/Dockerfile +++ b/Dockerfile @@ -12,7 +12,7 @@ RUN yarn install --frozen-lockfile # Bundle app source COPY . . -RUN yarn build +RUN yarn build:prod RUN chmod +x ./bin/wait-for-it.sh diff --git a/README.md b/README.md index 5aa0331b..086bd49e 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,8 @@ arora-discord ================ [![Discord](https://discordapp.com/api/guilds/761634353859395595/embed.png)](https://discord.gg/tJFNC5Y) -[![Depfu](https://badges.depfu.com/badges/523cd8158dc916da3de31abb92a5dfb0/count.svg)](https://depfu.com/github/guidojw/arora-discord?project_id=10393) -[![Build status](https://badge.buildkite.com/3270dad708bac251c357a7f2eab2c08ef710708bfb76120217.svg)](https://buildkite.com/guidos-projects/arora-discord) +[![Continuous Integration](https://github.com/guidojw/arora-discord/actions/workflows/continuous-integration.yml/badge.svg)](https://github.com/guidojw/arora-discord/actions/workflows/continuous-integration.yml) +[![Continuous Delivery](https://github.com/guidojw/arora-discord/actions/workflows/continuous-delivery.yml/badge.svg)](https://github.com/guidojw/arora-discord/actions/workflows/continuous-delivery.yml) **NOTE** This guide does currently not cover everything that should be done to set up the project. It misses important information about for example the bot's settings and that you need to run [Arora API](https://github.com/guidojw/arora-api) for the group commands. diff --git a/docker-compose.buildkite.yml b/docker-compose.buildkite.yml deleted file mode 100644 index d5e8674e..00000000 --- a/docker-compose.buildkite.yml +++ /dev/null @@ -1,17 +0,0 @@ -version: '3' - -services: - app: - build: - context: . - args: - - NODE_ENV=test - - app-production: &production - build: - context: . - args: - - BUILD_HASH=${BUILDKITE_COMMIT} - - app-staging: - <<: *production diff --git a/docker-compose.yml b/docker-compose.yml index f168ad6b..c6d5455b 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -2,11 +2,10 @@ version: '3' services: app: - image: docker.guidojw.nl/arora/arora-discord:latest + image: ghcr.io/guidojw/arora-discord:latest restart: unless-stopped env_file: .env environment: - - NODE_ENV=production - POSTGRES_HOST=db volumes: - /opt/app/node_modules diff --git a/package.json b/package.json index 6c4366f3..dbe2bf53 100644 --- a/package.json +++ b/package.json @@ -11,6 +11,7 @@ "main": "dist/app.js", "scripts": { "build": "tsc", + "build:prod": "tsc -p tsconfig.production.json", "lint": "eslint src", "start": "node dist/app.js", "test": "echo \"Error: no test specified\" && exit 1" diff --git a/tsconfig.production.json b/tsconfig.production.json new file mode 100644 index 00000000..5bb7fc8d --- /dev/null +++ b/tsconfig.production.json @@ -0,0 +1,8 @@ +{ + "extends": "./tsconfig", + "compilerOptions": { + "sourceMap": true, /* Generates corresponding '.map' file. */ + "sourceRoot": "/", /* Specify the location where debugger should locate TypeScript files instead of source locations. */ + "inlineSources": true, /* Emit the source alongside the sourcemaps within a single file; requires '--inlineSourceMap' or '--sourceMap' to be set. */ + } +}