Introduction

Most IT teams generate the same reports week after week — license usage, stale accounts, patch compliance, group membership changes. Done manually, these are tedious and easy to forget. Done with PowerShell and GitHub Actions, they run themselves on a schedule and land in your inbox without you touching anything.

This article walks through building a practical example: a scheduled workflow that queries Microsoft Graph for inactive user accounts, generates a CSV report, and emails it to the IT team automatically.

The Architecture

The setup is straightforward. A GitHub Actions workflow runs on a cron schedule, checks out a PowerShell script from your repository, authenticates to Microsoft Graph using a service principal, runs the report logic, and sends the result by email. Everything is version-controlled, auditable, and free to run within GitHub’s free tier for private repositories.

Prerequisites

  • A GitHub repository (private is fine)
  • An Entra ID service principal with User.Read.All permissions (application type, not delegated)
  • An SMTP relay or email service (Office 365, SendGrid, etc.) for sending the report

Creating the Service Principal

For unattended automation, you need a service principal with application permissions rather than delegated (user) permissions. In the Azure Portal, go to Entra ID → App registrations → New registration. Then under API permissions, add User.Read.All as an Application permission and grant admin consent.

Note down the Application (client) ID, Directory (tenant) ID, and create a Client secret under Certificates & secrets.

The PowerShell Report Script

Create a file called scripts/Get-InactiveUsers.ps1 in your repository:

param(
    [string]$TenantId     = $env:AZURE_TENANT_ID,
    [string]$ClientId     = $env:AZURE_CLIENT_ID,
    [string]$ClientSecret = $env:AZURE_CLIENT_SECRET,
    [int]$InactiveDays    = 90,
    [string]$OutputPath   = "./inactive-users.csv"
)

# Connect using client credentials (no browser prompt)
$secureSecret = ConvertTo-SecureString $ClientSecret -AsPlainText -Force
$credential   = New-Object System.Management.Automation.PSCredential($ClientId, $secureSecret)

Connect-MgGraph -TenantId $TenantId -ClientSecretCredential $credential -NoWelcome

$cutoffDate = (Get-Date).AddDays(-$InactiveDays).ToString("yyyy-MM-ddTHH:mm:ssZ")

# Get users who haven't signed in since the cutoff date
$inactiveUsers = Get-MgUser -All `
    -Filter "signInActivity/lastSignInDateTime le $cutoffDate" `
    -Property DisplayName, UserPrincipalName, SignInActivity, AccountEnabled, Department `
    | Where-Object { $_.AccountEnabled -eq $true }

$report = $inactiveUsers | Select-Object `
    DisplayName,
    UserPrincipalName,
    Department,
    @{ Name = "LastSignIn"; Expression = { $_.SignInActivity.LastSignInDateTime } },
    @{ Name = "DaysSinceSignIn"; Expression = {
        if ($_.SignInActivity.LastSignInDateTime) {
            [int]((Get-Date) - [datetime]$_.SignInActivity.LastSignInDateTime).TotalDays
        } else { "Never" }
    }}

$report | Export-Csv -Path $OutputPath -NoTypeInformation -Encoding UTF8

Write-Output "Found $($report.Count) inactive users. Report saved to $OutputPath"

Disconnect-MgGraph

Sending the Report by Email

Add a second script scripts/Send-Report.ps1 that sends the CSV as an email attachment via Microsoft Graph (no SMTP credentials needed if you’re in an Office 365 environment):

param(
    [string]$TenantId     = $env:AZURE_TENANT_ID,
    [string]$ClientId     = $env:AZURE_CLIENT_ID,
    [string]$ClientSecret = $env:AZURE_CLIENT_SECRET,
    [string]$FromAddress  = $env:REPORT_FROM_ADDRESS,
    [string]$ToAddress    = $env:REPORT_TO_ADDRESS,
    [string]$AttachmentPath = "./inactive-users.csv"
)

$secureSecret = ConvertTo-SecureString $ClientSecret -AsPlainText -Force
$credential   = New-Object System.Management.Automation.PSCredential($ClientId, $secureSecret)
Connect-MgGraph -TenantId $TenantId -ClientSecretCredential $credential -NoWelcome

$csvBytes      = [System.IO.File]::ReadAllBytes($AttachmentPath)
$csvBase64     = [Convert]::ToBase64String($csvBytes)
$reportDate    = Get-Date -Format "yyyy-MM-dd"

$message = @{
    subject = "Inactive User Report - $reportDate"
    body = @{
        contentType = "HTML"
        content     = "

Please find attached the inactive user report for $reportDate.

Users included have not signed in for 90+ days and have enabled accounts.

" } toRecipients = @(@{ emailAddress = @{ address = $ToAddress } }) attachments = @(@{ "@odata.type" = "#microsoft.graph.fileAttachment" name = "inactive-users-$reportDate.csv" contentType = "text/csv" contentBytes = $csvBase64 }) } Send-MgUserMail -UserId $FromAddress -Message $message -SaveToSentItems Write-Output "Report emailed to $ToAddress" Disconnect-MgGraph

Note: the sending mailbox user needs a Mail.Send application permission, and the service principal must be granted Send As rights on the shared mailbox.

The GitHub Actions Workflow

Create .github/workflows/inactive-user-report.yml:

name: Weekly Inactive User Report

on:
  schedule:
    - cron: '0 7 * * 1'   # Every Monday at 07:00 UTC
  workflow_dispatch:        # Allow manual runs

jobs:
  report:
    runs-on: ubuntu-latest

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

      - name: Install Microsoft Graph PowerShell module
        shell: pwsh
        run: |
          Install-Module Microsoft.Graph.Authentication -Scope CurrentUser -Force
          Install-Module Microsoft.Graph.Users -Scope CurrentUser -Force
          Install-Module Microsoft.Graph.Mail -Scope CurrentUser -Force

      - name: Generate inactive user report
        shell: pwsh
        env:
          AZURE_TENANT_ID:     ${{ secrets.AZURE_TENANT_ID }}
          AZURE_CLIENT_ID:     ${{ secrets.AZURE_CLIENT_ID }}
          AZURE_CLIENT_SECRET: ${{ secrets.AZURE_CLIENT_SECRET }}
        run: ./scripts/Get-InactiveUsers.ps1

      - name: Upload report as artifact
        uses: actions/upload-artifact@v4
        with:
          name: inactive-users-report
          path: inactive-users.csv
          retention-days: 30

      - name: Email report
        shell: pwsh
        env:
          AZURE_TENANT_ID:     ${{ secrets.AZURE_TENANT_ID }}
          AZURE_CLIENT_ID:     ${{ secrets.AZURE_CLIENT_ID }}
          AZURE_CLIENT_SECRET: ${{ secrets.AZURE_CLIENT_SECRET }}
          REPORT_FROM_ADDRESS: ${{ secrets.REPORT_FROM_ADDRESS }}
          REPORT_TO_ADDRESS:   ${{ secrets.REPORT_TO_ADDRESS }}
        run: ./scripts/Send-Report.ps1

The report is also uploaded as a GitHub Actions artifact, giving you a 30-day archive of every run without any additional storage setup.

Adding the Secrets to GitHub

In your repository, go to Settings → Secrets and variables → Actions and add:

  • AZURE_TENANT_ID
  • AZURE_CLIENT_ID
  • AZURE_CLIENT_SECRET
  • REPORT_FROM_ADDRESS — the mailbox to send from
  • REPORT_TO_ADDRESS — the IT team distribution list or email

Extending the Pattern

Once the pipeline is running, it’s easy to add more reports by dropping new scripts into the scripts/ folder and adding steps to the workflow. Good candidates for automation in a similar style include: license assignment audits, groups with no owners, devices not checked in to Intune, and MFA registration status. Each follows the same pattern — Graph query, CSV export, email attachment.

Wrapping Up

GitHub Actions isn’t just for software deployments. For IT teams, it’s a capable and free scheduler for PowerShell automation — with built-in secret management, run history, and artifact storage. Combining it with Microsoft Graph gives you a scriptable window into your entire Microsoft 365 and Entra ID environment, without maintaining any on-premise infrastructure to run the jobs.