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.Allpermissions (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_IDAZURE_CLIENT_IDAZURE_CLIENT_SECRETREPORT_FROM_ADDRESS— the mailbox to send fromREPORT_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.