Skip to content

Docs | README.md Update #187

Docs | README.md Update

Docs | README.md Update #187

# yaml-language-server: $schema=https://json.schemastore.org/github-workflow.json
# GitHub Actions Workflow: Deploy TomoriBot to AWS ECS
# Note: Secret validation warnings are expected - secrets are configured in GitHub repo settings
name: Deploy TomoriBot
on:
push:
branches:
- main
workflow_dispatch: # Manual trigger - allows running on any branch
inputs:
environment:
description: "Deployment environment"
required: false
type: choice
options:
- test
- production
default: test
create_db_snapshot:
description: "Create a pre-deploy RDS snapshot"
required: false
type: boolean
default: false
# OIDC permissions for AWS authentication
permissions:
id-token: write
contents: write
pull-requests: write
jobs:
# SECRETS VALIDATION
validate-secrets:
runs-on: ubuntu-latest
steps:
- name: Validate deployment infrastructure secrets
run: |
echo "=========================================="
echo "Validating GitHub Secrets (Infrastructure Only)"
echo "=========================================="
echo ""
echo "NOTE: This workflow ONLY validates deployment/infrastructure secrets."
echo "Application secrets (DISCORD_TOKEN, POSTGRES_PASSWORD, etc.) are fetched"
echo "by the bot from AWS Secrets Manager at runtime via secretsManager.ts."
echo ""
missing_secrets=()
# Infrastructure secrets required for deployment
echo "Checking infrastructure secrets..."
if [ -z "${{ secrets.AWS_ROLE_ARN }}" ]; then missing_secrets+=("AWS_ROLE_ARN"); fi
if [ -z "${{ secrets.AWS_REGION }}" ]; then missing_secrets+=("AWS_REGION"); fi
if [ -z "${{ secrets.ECR_REPOSITORY_NAME }}" ]; then missing_secrets+=("ECR_REPOSITORY_NAME"); fi
if [ -z "${{ secrets.RDS_MASTER_PASSWORD }}" ]; then missing_secrets+=("RDS_MASTER_PASSWORD"); fi
if [ -z "${{ secrets.PAT_TOKEN }}" ]; then missing_secrets+=("PAT_TOKEN"); fi
# Optional secrets
optional_secrets=()
if [ -z "${{ secrets.DISCORD_WEBHOOK_URL }}" ]; then optional_secrets+=("DISCORD_WEBHOOK_URL"); fi
if [ ${#missing_secrets[@]} -gt 0 ]; then
echo ""
echo "Missing required infrastructure secrets: ${missing_secrets[*]}"
echo ""
echo "These secrets are needed by GitHub Actions to deploy to AWS."
echo "Add them in: GitHub Repository → Settings → Secrets and variables → Actions"
exit 1
fi
if [ ${#optional_secrets[@]} -gt 0 ]; then
echo " Optional secrets not configured: ${optional_secrets[*]}"
fi
echo ""
echo "All required infrastructure secrets are configured!"
echo ""
echo "================================================"
echo "Infrastructure Secrets: VALIDATED"
echo "Application Secrets: Fetched from AWS at runtime"
echo "================================================"
# PARALLEL TESTING PHASE
test-lint:
runs-on: ubuntu-latest
needs: validate-secrets
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup Bun
uses: oven-sh/setup-bun@v1
with:
bun-version: latest
- name: Install dependencies
run: |
echo "Installing dependencies..."
bun install
echo "Dependencies installed!"
- name: Run linting and type checks
run: |
echo "Running linting and TypeScript checks..."
bun run lint
bun run check
echo "All linting and type checks passed!"
test-locales:
runs-on: ubuntu-latest
needs: validate-secrets
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup Bun
uses: oven-sh/setup-bun@v1
with:
bun-version: latest
- name: Install dependencies
run: bun install
- name: Check localization keys
run: |
echo "Checking localization keys..."
output=$(bun run check-locales 2>&1)
exitCode=$?
# Always show the output
echo "$output"
# Check if there are missing keys (critical errors)
if echo "$output" | grep -q "MISSING LOCALIZATION KEYS"; then
echo "Critical error: Missing localization keys found!"
exit 1
elif [ $exitCode -eq 0 ]; then
echo "All localization checks passed!"
else
echo "Localization warnings found, but no critical missing keys. Continuing deployment..."
fi
# BUILD DOCKER IMAGE
build-image:
runs-on: ubuntu-latest
needs: [test-lint, test-locales]
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Log in to Docker Hub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Setup Bun
uses: oven-sh/setup-bun@v1
with:
bun-version: latest
- name: Install dependencies
run: bun install --frozen-lockfile
- name: Download tokenizer assets for logit bias
env:
HF_TOKEN: ${{ secrets.HF_TOKEN }}
run: |
echo "Downloading tokenizer assets from HuggingFace..."
bun run scripts/downloadTokenizers.ts
echo "Tokenizer families:"
du -sh tokenizers/*/
- name: Pre-download Python packages with platform-specific wheels
run: |
echo "Downloading Python packages (musllinux wheels for Alpine 3.20 Python 3.12)..."
# Create directory for pip packages
mkdir -p docker-pip-cache
# Download platform-specific wheels for Alpine Linux (musllinux)
# Target Python 3.12 (used by Alpine 3.20) with musllinux_1_2 compatibility
pip3 download \
--dest docker-pip-cache \
--platform musllinux_1_2_x86_64 \
--platform musllinux_1_1_x86_64 \
--python-version 3.12 \
--implementation cp \
--abi cp312 \
--only-binary :all: \
mcp-server-fetch==2025.4.7
echo "Packages downloaded to docker-pip-cache/"
ls -lah docker-pip-cache/
- name: Build Docker image
run: |
echo "Building TomoriBot Docker image..."
docker buildx build \
--platform linux/amd64 \
--tag tomoribot:latest \
--tag tomoribot:${{ github.sha }} \
--load \
.
- name: Save Docker image
run: |
echo "Saving Docker image for security scanning..."
docker save tomoribot:latest -o tomoribot-image.tar
- name: Upload Docker image artifact
uses: actions/upload-artifact@v4
with:
name: docker-image
path: tomoribot-image.tar
retention-days: 1
# PHASE 4: SECURITY SCANNING
security-sast:
runs-on: ubuntu-latest
needs: build-image
outputs:
failed: ${{ steps.check-result.outputs.failed }}
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Log in to Docker Hub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Run Semgrep SAST scan
uses: returntocorp/semgrep-action@v1
with:
config: >-
p/security-audit
p/typescript
p/docker
continue-on-error: true
id: semgrep
- name: Check Semgrep results
id: check-result
run: |
if [ "${{ steps.semgrep.outcome }}" == "failure" ]; then
echo "Semgrep found potential security issues. Review the output above."
echo "failed=true" >> $GITHUB_OUTPUT
else
echo "SAST scan passed"
echo "failed=false" >> $GITHUB_OUTPUT
fi
security-dependencies:
runs-on: ubuntu-latest
needs: build-image
outputs:
failed: ${{ steps.check-result.outputs.failed }}
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup Bun
uses: oven-sh/setup-bun@v1
with:
bun-version: latest
- name: Install dependencies
run: bun install
- name: Run Bun audit
run: |
echo "Running dependency vulnerability scan..."
# Run bun audit and capture output
if ! bun audit --audit-level=high > audit-output.txt 2>&1; then
echo "High or critical vulnerabilities found in dependencies:"
cat audit-output.txt
exit 1
else
echo "No high or critical vulnerabilities found in dependencies"
cat audit-output.txt
fi
continue-on-error: true
id: bun-audit
- name: Check audit results
id: check-result
run: |
if [ "${{ steps.bun-audit.outcome }}" == "failure" ]; then
echo "failed=true" >> $GITHUB_OUTPUT
else
echo "failed=false" >> $GITHUB_OUTPUT
fi
security-secrets:
runs-on: ubuntu-latest
needs: build-image
outputs:
failed: ${{ steps.check-result.outputs.failed }}
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
fetch-depth: 0 # Fetch full history for TruffleHog
- name: Log in to Docker Hub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Run TruffleHog secret scan
uses: trufflesecurity/trufflehog@main
with:
path: ./
base: ${{ github.event.before }}
head: ${{ github.event.after }}
extra_args: --only-verified
continue-on-error: true
id: trufflehog
- name: Check TruffleHog results
id: check-result
run: |
if [ "${{ steps.trufflehog.outcome }}" == "failure" ]; then
echo "TruffleHog found exposed secrets in git history!"
echo "failed=true" >> $GITHUB_OUTPUT
else
echo "Secret scan passed"
echo "failed=false" >> $GITHUB_OUTPUT
fi
security-container:
runs-on: ubuntu-latest
needs: build-image
outputs:
failed: ${{ steps.check-result.outputs.failed }}
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Log in to Docker Hub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Download Docker image artifact
uses: actions/download-artifact@v4
with:
name: docker-image
- name: Load Docker image
run: docker load -i tomoribot-image.tar
- name: Run Trivy container scan
uses: aquasecurity/trivy-action@master
with:
image-ref: tomoribot:latest
format: table
exit-code: 1
severity: CRITICAL,HIGH
ignore-unfixed: true
trivyignores: .trivyignore
continue-on-error: true
id: trivy
- name: Check Trivy results
id: check-result
run: |
if [ "${{ steps.trivy.outcome }}" == "failure" ]; then
echo "Trivy found critical or high vulnerabilities in container image!"
echo "failed=true" >> $GITHUB_OUTPUT
else
echo "Container scan passed"
echo "failed=false" >> $GITHUB_OUTPUT
fi
# SECURITY GATE: Block deployment if critical/high vulnerabilities found
security-gate:
runs-on: ubuntu-latest
needs:
[
security-sast,
security-dependencies,
security-secrets,
security-container,
]
steps:
- name: Check security scan results
run: |
echo "=========================================="
echo "Security Gate - Evaluating Scan Results"
echo "=========================================="
echo ""
failed=false
# Check each security scan output
echo "SAST Scan (Semgrep): ${{ needs.security-sast.outputs.failed }}"
if [ "${{ needs.security-sast.outputs.failed }}" == "true" ]; then
echo "SAST scan FAILED - Semgrep found security issues"
failed=true
else
echo "SAST scan passed"
fi
echo ""
echo "Dependency Scan (Bun Audit): ${{ needs.security-dependencies.outputs.failed }}"
if [ "${{ needs.security-dependencies.outputs.failed }}" == "true" ]; then
echo "Dependency scan FAILED - High/Critical CVEs found"
failed=true
else
echo "Dependency scan passed"
fi
echo ""
echo "Secret Scan (TruffleHog): ${{ needs.security-secrets.outputs.failed }}"
if [ "${{ needs.security-secrets.outputs.failed }}" == "true" ]; then
echo "Secret scan FAILED - Exposed secrets found in git history"
failed=true
else
echo "Secret scan passed"
fi
echo ""
echo "Container Scan (Trivy): ${{ needs.security-container.outputs.failed }}"
if [ "${{ needs.security-container.outputs.failed }}" == "true" ]; then
echo "Container scan FAILED - Critical/High vulnerabilities in image"
failed=true
else
echo "Container scan passed"
fi
echo ""
echo "=========================================="
if [ "$failed" == "true" ]; then
echo "DEPLOYMENT BLOCKED"
echo "=========================================="
echo ""
echo "Security scans found critical or high severity issues."
echo "Please review the scan outputs above and fix the issues before deploying."
echo ""
exit 1
else
echo "ALL SECURITY SCANS PASSED"
echo "=========================================="
echo ""
echo "Proceeding with deployment to AWS..."
fi
# PHASE 5: AWS DEPLOYMENT
push-to-ecr:
runs-on: ubuntu-latest
needs: security-gate
outputs:
image_uri: ${{ steps.ecr_uri.outputs.uri }}
deploy: ${{ steps.ecr_uri.outputs.deploy }}
version: ${{ steps.package_version.outputs.version }}
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Download Docker image artifact
uses: actions/download-artifact@v4
with:
name: docker-image
- name: Load Docker image
run: docker load -i tomoribot-image.tar
- name: Configure AWS Credentials (OIDC)
uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: ${{ secrets.AWS_ROLE_ARN }}
aws-region: ${{ secrets.AWS_REGION }}
- name: Login to Amazon ECR
id: login-ecr
uses: aws-actions/amazon-ecr-login@v2
- name: Get version from package.json
id: package_version
run: |
VERSION=$(jq -r '.version' package.json)
echo "version=$VERSION" >> $GITHUB_OUTPUT
echo "Package version: v$VERSION"
- name: Check if version exists in ECR
id: version_check
env:
ECR_REGISTRY: ${{ steps.login-ecr.outputs.registry }}
ECR_REPOSITORY: ${{ secrets.ECR_REPOSITORY_NAME }}
VERSION: ${{ steps.package_version.outputs.version }}
run: |
# Check if version tag exists in ECR
if aws ecr describe-images \
--registry-id ${ECR_REGISTRY%%.*} \
--repository-name $ECR_REPOSITORY \
--image-ids imageTag=v$VERSION \
>/dev/null 2>&1; then
echo "version_exists=true" >> $GITHUB_OUTPUT
echo " Version v$VERSION already exists in ECR - skipping deployment"
else
echo "version_exists=false" >> $GITHUB_OUTPUT
echo "Version v$VERSION is new - will deploy"
fi
- name: Tag and push image to ECR
id: ecr_uri
env:
ECR_REGISTRY: ${{ steps.login-ecr.outputs.registry }}
ECR_REPOSITORY: ${{ secrets.ECR_REPOSITORY_NAME }}
IMAGE_TAG: ${{ github.sha }}
VERSION: ${{ steps.package_version.outputs.version }}
VERSION_EXISTS: ${{ steps.version_check.outputs.version_exists }}
run: |
echo "Tagging image with commit hash..."
docker tag tomoribot:latest $ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG
docker push $ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG
echo "Pushed commit hash tag: $IMAGE_TAG"
# Always update 'latest' tag for debugging
docker tag tomoribot:latest $ECR_REGISTRY/$ECR_REPOSITORY:latest
docker push $ECR_REGISTRY/$ECR_REPOSITORY:latest
echo "Updated 'latest' tag"
# Tag with semantic version if new
if [ "$VERSION_EXISTS" = "false" ]; then
echo "Tagging image with semantic version..."
docker tag tomoribot:latest $ECR_REGISTRY/$ECR_REPOSITORY:v$VERSION
docker push $ECR_REGISTRY/$ECR_REPOSITORY:v$VERSION
echo "Pushed version tag: v$VERSION"
# Use version tag for deployment
echo "uri=$ECR_REGISTRY/$ECR_REPOSITORY:v$VERSION" >> $GITHUB_OUTPUT
echo "deploy=true" >> $GITHUB_OUTPUT
else
echo " Skipping version tag (already exists)"
echo "deploy=false" >> $GITHUB_OUTPUT
# Output commit hash as fallback (won't be used for deployment)
echo "uri=$ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG" >> $GITHUB_OUTPUT
fi
- name: Deployment summary
env:
VERSION: ${{ steps.package_version.outputs.version }}
DEPLOY: ${{ steps.ecr_uri.outputs.deploy }}
run: |
echo "=================================="
echo "Version: v$VERSION"
echo "Deploy: $DEPLOY"
if [ "$DEPLOY" = "true" ]; then
echo "Deployment will proceed with version v$VERSION"
else
echo "Deployment skipped - version v$VERSION already exists"
echo "To deploy, increment version in package.json"
fi
echo "=================================="
deploy-with-terraform:
runs-on: ubuntu-latest
needs: push-to-ecr
# Only deploy if version is new (not already in ECR)
if: needs['push-to-ecr'].outputs.deploy == 'true'
env:
TF_VAR_container_image: ${{ needs['push-to-ecr'].outputs.image_uri }}
TF_VAR_rds_master_password: ${{ secrets.RDS_MASTER_PASSWORD }}
TF_IN_AUTOMATION: "1"
TF_INPUT: "0"
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Configure AWS Credentials (OIDC)
uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: ${{ secrets.AWS_ROLE_ARN }}
aws-region: ${{ secrets.AWS_REGION }}
- name: Resolve image URI
env:
AWS_REGION: ${{ secrets.AWS_REGION }}
ECR_REPOSITORY_NAME: ${{ secrets.ECR_REPOSITORY_NAME }}
run: |
if [ -z "${TF_VAR_container_image}" ]; then
ACCOUNT_ID=$(aws sts get-caller-identity --query Account --output text)
IMAGE_URI="${ACCOUNT_ID}.dkr.ecr.${AWS_REGION}.amazonaws.com/${ECR_REPOSITORY_NAME}:${GITHUB_SHA}"
echo "TF_VAR_container_image=$IMAGE_URI" >> $GITHUB_ENV
TF_VAR_container_image="$IMAGE_URI"
echo "Resolved image URI from AWS account: ${TF_VAR_container_image}"
else
echo "Using image URI from push-to-ecr output: ${TF_VAR_container_image}"
fi
- name: Verify image exists in ECR
env:
ECR_REPOSITORY_NAME: ${{ secrets.ECR_REPOSITORY_NAME }}
run: |
aws ecr describe-images \
--repository-name "$ECR_REPOSITORY_NAME" \
--image-ids imageTag="$GITHUB_SHA" \
--query 'imageDetails[0].imageTags' \
--output text >/dev/null
- name: Validate image URI
run: |
if [ -z "${TF_VAR_container_image}" ]; then
echo "TF_VAR_container_image is empty. Re-run from push-to-ecr or full workflow."
exit 1
fi
echo "Deploying image: ${TF_VAR_container_image}"
- name: Validate Secrets Manager database target
env:
AWS_REGION: ${{ secrets.AWS_REGION }}
run: |
secret_json=$(aws secretsmanager get-secret-value \
--secret-id tomoribot/production \
--query SecretString \
--output text)
secret_host=$(echo "$secret_json" | jq -r '.POSTGRES_HOST // empty')
secret_db=$(echo "$secret_json" | jq -r '.POSTGRES_DB // empty')
if [ -z "$secret_host" ] || [ -z "$secret_db" ]; then
echo "Secrets Manager is missing POSTGRES_HOST or POSTGRES_DB. Aborting deploy."
exit 1
fi
if [ "$secret_host" != "tomoribot-db.cszuse2muutq.us-east-1.rds.amazonaws.com" ]; then
echo "POSTGRES_HOST does not match the expected RDS endpoint. Aborting deploy."
exit 1
fi
if [ "$secret_db" != "tomoribot" ]; then
echo "POSTGRES_DB does not match expected value. Aborting deploy."
exit 1
fi
echo "Secrets Manager database target validated."
- name: Setup Terraform
uses: hashicorp/setup-terraform@v3
- name: Optional RDS snapshot (manual or (Checkpoint) commit)
if: |
(github.event_name == 'workflow_dispatch' && inputs.create_db_snapshot == true) ||
(github.event_name == 'push' && contains(github.event.head_commit.message, '(Checkpoint)'))
run: |
SNAPSHOT_ID="tomoribot-db-pre-deploy-${{ github.run_id }}-${{ github.run_attempt }}"
aws rds create-db-snapshot \
--db-instance-identifier tomoribot-db \
--db-snapshot-identifier "$SNAPSHOT_ID"
- name: Terraform init
working-directory: terraform
run: terraform init
- name: Terraform plan
working-directory: terraform
run: terraform plan -input=false -var-file=terraform.ci.tfvars -out=tfplan
- name: Guard against RDS destroy/replace
working-directory: terraform
run: |
echo "Checking plan for destructive RDS changes..."
if terraform show -json tfplan | jq -e '.resource_changes[]
| select(.type=="aws_db_instance")
| select(.change.actions | index("delete"))' >/dev/null; then
echo "RDS instance would be destroyed or replaced. Aborting deploy."
exit 1
fi
- name: Terraform apply
working-directory: terraform
run: terraform apply -input=false -auto-approve tfplan
verify-ecs-deployment:
runs-on: ubuntu-latest
needs: deploy-with-terraform
outputs:
deployment-status: ${{ steps.verify.outputs.status }}
commit-hash: ${{ steps.commit-info.outputs.hash }}
commit-message: ${{ steps.commit-info.outputs.message }}
env:
ECS_CLUSTER: tomoribot-cluster
ECS_SERVICE: tomoribot-service
AWS_REGION: ${{ secrets.AWS_REGION }}
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Get commit information
id: commit-info
run: |
hash=$(git rev-parse --short HEAD)
message=$(git log -1 --pretty=format:"%s")
echo "hash=$hash" >> $GITHUB_OUTPUT
echo "message=$message" >> $GITHUB_OUTPUT
- name: Configure AWS Credentials (OIDC)
uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: ${{ secrets.AWS_ROLE_ARN }}
aws-region: ${{ secrets.AWS_REGION }}
- name: Wait for ECS service stability
id: verify
run: |
echo "Waiting for ECS service to stabilize..."
# Wait for service to reach steady state (max 10 minutes)
if aws ecs wait services-stable \
--cluster $ECS_CLUSTER \
--services $ECS_SERVICE; then
echo "ECS service is stable!"
echo "status=success" >> $GITHUB_OUTPUT
else
echo "ECS service failed to stabilize!"
echo "status=failed" >> $GITHUB_OUTPUT
exit 1
fi
- name: Check ECS task health
if: always()
run: |
echo "Checking ECS task health..."
# Get running tasks
TASKS=$(aws ecs list-tasks \
--cluster $ECS_CLUSTER \
--service-name $ECS_SERVICE \
--desired-status RUNNING \
--query 'taskArns' \
--output text)
if [ -z "$TASKS" ]; then
echo "No running tasks found!"
exit 1
fi
# Describe tasks
aws ecs describe-tasks \
--cluster $ECS_CLUSTER \
--tasks $TASKS \
--query 'tasks[*].[taskArn, lastStatus, healthStatus, containers[0].healthStatus]' \
--output table
echo "Task health check complete!"
- name: Check recent CloudWatch logs
if: always()
run: |
echo "Fetching recent CloudWatch logs..."
# Get logs from the last 5 minutes
START_TIME=$(($(date +%s) - 300))000
aws logs tail /ecs/tomoribot \
--since ${START_TIME} \
--format short \
--follow=false \
|| echo "No recent logs found (this is normal for new deployments)"
# PHASE 6: GITHUB RELEASES (Source Code Only)
# Creates GitHub releases with source code and release notes when .github/release/vX.X.X/ folder exists
# Docker images are NOT included - users should clone and use docker-compose.yaml for self-hosting
# Future: May include installer packages (.exe, .app) for easier end-user setup
check-release-assets:
runs-on: ubuntu-latest
needs: verify-ecs-deployment
if: needs.verify-ecs-deployment.outputs.deployment-status == 'success'
outputs:
has-assets: ${{ steps.check.outputs.has-assets }}
version: ${{ steps.version.outputs.version }}
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Get version from package.json
id: version
run: |
version=$(jq -r '.version' package.json)
echo "version=$version" >> $GITHUB_OUTPUT
- name: Check if version-specific release folder exists
id: check
env:
VERSION: ${{ steps.version.outputs.version }}
run: |
versionFolder=".github/release/v$VERSION"
if [ -d "$versionFolder" ]; then
echo "Version-specific release folder found: $versionFolder"
echo "has-assets=true" >> $GITHUB_OUTPUT
else
echo "No version-specific release folder found: $versionFolder"
echo "This appears to be a minor update that doesn't require a release announcement"
echo "has-assets=false" >> $GITHUB_OUTPUT
fi
create-release:
runs-on: ubuntu-latest
needs: [verify-ecs-deployment, check-release-assets]
if: needs.verify-ecs-deployment.outputs.deployment-status == 'success' && needs.check-release-assets.outputs.has-assets == 'true'
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Check if GitHub release already exists
id: release-check
env:
GH_TOKEN: ${{ secrets.PAT_TOKEN }}
VERSION: ${{ needs.check-release-assets.outputs.version }}
run: |
if gh release view "v$VERSION" >/dev/null 2>&1; then
echo "Release v$VERSION already exists. Skipping release creation."
echo "exists=true" >> $GITHUB_OUTPUT
else
echo "Release v$VERSION does not exist. Proceeding."
echo "exists=false" >> $GITHUB_OUTPUT
fi
- name: Prepare release notes and Discord notification
id: release-notes
if: steps.release-check.outputs.exists != 'true'
env:
VERSION: ${{ needs.check-release-assets.outputs.version }}
COMMIT_HASH: ${{ needs.verify-ecs-deployment.outputs.commit-hash }}
run: |
versionFolder=".github/release/v$VERSION"
# Find first .md file in version folder
releaseNotesPath=$(find "$versionFolder" -maxdepth 1 -name "*.md" ! -name "README.md" | head -n 1)
if [ -z "$releaseNotesPath" ]; then
echo "No .md files found in $versionFolder"
exit 1
fi
echo "Using release notes: $releaseNotesPath"
# Find first image file in version folder
imageName=$(find "$versionFolder" -maxdepth 1 -type f \( -name "*.png" -o -name "*.jpg" -o -name "*.jpeg" -o -name "*.gif" -o -name "*.webp" \) | head -n 1 | xargs basename)
if [ -z "$imageName" ]; then
echo "No image file found, Discord notification will be text-only"
imageName=""
else
echo "Image found: $imageName"
fi
# Read and process release notes
releaseNotes=$(cat "$releaseNotesPath")
releaseNotes="${releaseNotes//\{VERSION\}/$VERSION}"
releaseNotes="${releaseNotes//\{TIMESTAMP\}/$(date -u +"%Y-%m-%d %H:%M:%S UTC")}"
releaseNotes="${releaseNotes//\{COMMIT_HASH\}/$COMMIT_HASH}"
releaseNotes="${releaseNotes//\{REPO_OWNER\}/${{ github.repository_owner }}}"
releaseNotes="${releaseNotes//\{REPO_NAME\}/${{ github.event.repository.name }}}"
# Save processed release notes
echo "$releaseNotes" > processed-release-notes.md
echo "image-name=$imageName" >> $GITHUB_OUTPUT
- name: Create GitHub Release (Source Code Only)
if: steps.release-check.outputs.exists != 'true'
env:
GH_TOKEN: ${{ secrets.PAT_TOKEN }}
VERSION: ${{ needs.check-release-assets.outputs.version }}
run: |
echo "Creating GitHub Release v$VERSION (source code only)..."
gh release create "v$VERSION" \
--title "TomoriBot v$VERSION" \
--notes-file "processed-release-notes.md" \
--latest
echo "GitHub release created!"
echo ""
echo "Note: Users can download source code and use 'docker-compose up' for self-hosting."
echo "Future releases may include installer packages (.exe, .app, etc.) for easier setup."
- name: Send Discord notification
if: steps.release-check.outputs.exists != 'true'
env:
STATUS: ${{ needs.verify-ecs-deployment.outputs.deployment-status }}
VERSION: ${{ needs.check-release-assets.outputs.version }}
IMAGE_NAME: ${{ steps.release-notes.outputs.image-name }}
MENTION_ROLE_ID: ${{ secrets.RELEASE_DISCORD_MENTION_ROLE_ID }}
run: |
if [ "$STATUS" == "success" ]; then
statusLabel="✅ Deployed successfully"
else
statusLabel="❌ Deployment failed"
fi
# Read release notes and strip markdown image syntax (![alt](url) patterns)
releaseNotes=$(cat processed-release-notes.md)
releaseNotes=$(echo "$releaseNotes" | sed 's/!\[[^]]*\]([^)]*)//g')
# Write processed notes to file so Python can read them safely
printf '%s' "$releaseNotes" > discord_release_notes.txt
# Split release notes into Discord-safe chunks
python3 .github/scripts/discord_split.py
chunkCount=$(cat discord_chunk_count.txt)
echo "Prepared $chunkCount Discord message(s)"
# Attempt to download the release image; it becomes the first attachment
# in the forum post, which Discord uses as the post's preview image
hasImage=false
if [ -n "$IMAGE_NAME" ]; then
imageUrl="https://github.com/${{ github.repository }}/raw/main/.github/release/v$VERSION/$IMAGE_NAME"
echo "Downloading release image: $imageUrl"
if curl -sL --max-time 30 "$imageUrl" -o "release-post-image.png" && [ -s "release-post-image.png" ]; then
hasImage=true
echo "Release image downloaded"
else
echo "Could not download image — forum post will be text-only"
fi
fi
discordWebhookUrl="${{ secrets.DISCORD_WEBHOOK_URL }}"
if [ -z "$discordWebhookUrl" ]; then
echo "DISCORD_WEBHOOK_URL not configured — skipping Discord notification"
exit 0
fi
# 1. Create the forum thread (thread_name triggers forum-post creation)
# Extract title from first H1 line of release notes (strips leading "# ")
threadTitle=$(head -1 processed-release-notes.md | sed 's/^# *//')
firstChunk=$(cat discord_chunk_0.txt)
echo "Creating Discord forum post: $threadTitle"
if [ "$hasImage" = true ]; then
# Upload image as a form attachment so it appears as the post's lead image
firstPayload=$(jq -n \
--arg content "$firstChunk" \
--arg thread_name "$threadTitle" \
'{content: $content, thread_name: $thread_name}')
response=$(curl -s -w "\n%{http_code}" -X POST \
-F "payload_json=$firstPayload" \
-F "files[0]=@release-post-image.png" \
"${discordWebhookUrl}?wait=true")
else
firstPayload=$(jq -n \
--arg content "$firstChunk" \
--arg thread_name "$threadTitle" \
'{content: $content, thread_name: $thread_name}')
response=$(curl -s -w "\n%{http_code}" -X POST \
-H "Content-Type: application/json" \
-d "$firstPayload" \
"${discordWebhookUrl}?wait=true")
fi
httpCode=$(echo "$response" | tail -1)
responseBody=$(echo "$response" | sed '$d')
if [ "$httpCode" -lt 200 ] || [ "$httpCode" -ge 300 ]; then
echo "Failed to create Discord forum post (HTTP $httpCode)"
echo "Response: $responseBody"
exit 1
fi
echo "Forum post created (HTTP $httpCode)"
# 2. Extract the created thread ID from the response message object;
# in Discord forum webhooks the message's channel_id IS the thread ID
threadId=$(echo "$responseBody" | jq -r '.channel_id // empty')
# 3. Send any remaining text chunks as replies inside the same thread
if [ "$chunkCount" -gt 1 ] && [ -n "$threadId" ]; then
i=1
while [ "$i" -lt "$chunkCount" ]; do
chunk=$(cat "discord_chunk_${i}.txt")
chunkPayload=$(jq -n --arg content "$chunk" '{content: $content}')
replyResponse=$(curl -s -w "\n%{http_code}" -X POST \
-H "Content-Type: application/json" \
-d "$chunkPayload" \
"${discordWebhookUrl}?thread_id=${threadId}")
replyCode=$(echo "$replyResponse" | tail -1)
if [ "$replyCode" -lt 200 ] || [ "$replyCode" -ge 300 ]; then
echo "Warning: Failed to send message chunk $i (HTTP $replyCode)"
else
echo "Sent message chunk $((i + 1))/$chunkCount (HTTP $replyCode)"
fi
i=$((i + 1))
sleep 1 # Respect Discord rate limits between messages
done
elif [ "$chunkCount" -gt 1 ] && [ -z "$threadId" ]; then
echo "Warning: Could not extract thread ID — remaining $((chunkCount - 1)) chunk(s) skipped"
echo "Response body: $responseBody"
fi
# 4. Send role mention as the final message in the thread so it pings
# subscribers after all release content has been posted
if [ -n "$MENTION_ROLE_ID" ] && [ -n "$threadId" ]; then
mentionPayload=$(jq -n --arg content "<@&${MENTION_ROLE_ID}> Follow-up hotfixes or patches for this update will be posted in this Forum post, feel free to follow this post to keep track of them!" '{content: $content}')
mentionResponse=$(curl -s -w "\n%{http_code}" -X POST \
-H "Content-Type: application/json" \
-d "$mentionPayload" \
"${discordWebhookUrl}?thread_id=${threadId}")
mentionCode=$(echo "$mentionResponse" | tail -1)
if [ "$mentionCode" -lt 200 ] || [ "$mentionCode" -ge 300 ]; then
echo "Warning: Failed to send role mention (HTTP $mentionCode)"
else
echo "Role mention sent (HTTP $mentionCode)"
fi
elif [ -n "$MENTION_ROLE_ID" ] && [ -z "$threadId" ]; then
echo "Warning: Role mention skipped — thread ID unavailable"
fi
echo "Discord forum notification complete!"