A CD/CI Pipeline for .NET Project in Github Action

Published at 2026-01-11

The pipeline builds a docker image and push it to ghcr.io. It also deploys the docker container in the designated server using SSH.

name: Build and Deploy

on:
# Triggers the pipeline when a new git tag is pushed on master or main branch
  push:
    branches: [ "master", "main" ]
    tags: [ 'v*.*.*' ]
  pull_request:
    branches: [ "master", "main" ]

concurrency:
  group: ${{ github.workflow }}-${{ github.ref }}
  cancel-in-progress: true

env:
  REGISTRY: ghcr.io
  IMAGE_NAME: ${{ github.repository }}

jobs:
  build:
    runs-on: self-hosted
    outputs:
      tags: ${{ steps.meta.outputs.tags }}
      labels: ${{ steps.meta.outputs.labels }}
    steps:
      - uses: actions/checkout@v4

# using buildx so we can utilize the advanced cache ability. We can also use it for multi-platform docker build 
      - name: Set up buildx (single-platform, with cache)
        uses: docker/setup-buildx-action@v3

      - id: meta
        name: Extract metadata (tags, labels)
        uses: docker/metadata-action@v5
        with:
          images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
          tags: |
            # Tag with the full version (e.g. v1.2.3) when a git tag is pushed
            type=semver,pattern=v{{version}}
            # Keep your existing tags
            type=raw,value=latest
            # type=sha
# It builds the docker image for checking errors but not push it to the github docker registry. 
      - name: Build Docker image
        uses: docker/build-push-action@v5
        with:
          context: ./Spikylin
          push: false
          tags: ${{ steps.meta.outputs.tags }}
          labels: ${{ steps.meta.outputs.labels }}
          cache-from: type=gha
          cache-to: type=gha,mode=max

  push:
    runs-on: self-hosted
    needs: build
    if: |
      startsWith(github.ref, 'refs/tags/')
    steps:
      - uses: actions/checkout@v4
      
      - name: Set up buildx (single-platform, with cache)
        uses: docker/setup-buildx-action@v3

      - name: Log in to the Container registry
        uses: docker/login-action@v3
        with:
          registry: ${{ env.REGISTRY }}
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}

      - name: Push Docker image
        uses: docker/build-push-action@v5
        with:
          context: ./Spikylin
          push: true
          tags: ${{ needs.build.outputs.tags }}
          labels: ${{ needs.build.outputs.labels }}
          provenance: false # Prevent push the OCI artifact (like a provenance or SBOM file) 
          sbom: false # Prevent push the OCI artifact (like a provenance or SBOM file) 
          cache-from: type=gha
          cache-to: type=gha,mode=max

  deploy:
    needs: push
    # Only run deploy on tag pushes or when a tag is provided to workflow_dispatch
    # Because the github action runner is on the same server as the server I want to deploy to, I connect the server with SSH to the localhost. This avoids permission issues as the github action runs with a different user. When the runner runs as a different user, it might not see other docker containers or docker networks. 
    if: |
      startsWith(github.ref, 'refs/tags/') 
    runs-on: self-hosted
    env:
      REGISTRY: ghcr.io

    steps:
      - name: Checkout
        uses: actions/checkout@v4

      - name: Determine image tag
        id: tag
        run: |
          if [[ "${GITHUB_REF}" == refs/tags/* ]]; then
            TAG=${GITHUB_REF#refs/tags/}
          elif [ -n "${{ github.event.inputs.tag }}" ]; then
            TAG=${{ github.event.inputs.tag }}
          else
            TAG=latest
          fi
          echo "tag=$TAG" >> $GITHUB_OUTPUT

      - name: Deploy via SSH Loopback (localhost)
        uses: appleboy/[email protected]
        with:
          host: 127.0.0.1
          username: ${{ secrets.SERVER_USER }}
          key: ${{ secrets.SSH_PRIVATE_KEY }}
          script: |
            echo "${{ secrets.GITHUB_TOKEN }}" | docker login ${{ env.REGISTRY }} -u ${{ github.actor }} --password-stdin
            export TAG=${{ steps.tag.outputs.tag }}
            export IMAGE=${{ env.REGISTRY }}/${{ github.repository }}:$TAG
            docker pull $IMAGE
            docker compose -f ${{ github.workspace }}/Spikylin/docker-compose.yml -p spikylin up -d --remove-orphans

      - name: Announce deployment
        run: |
          echo "Successfully deployed ${{ github.repository }}:${{ steps.tag.outputs.tag }} via SSH loopback"