Skip to content

Update License SKU Data #7

Update License SKU Data

Update License SKU Data #7

name: Update License SKU Data
# Refreshes the Microsoft license SKU data (ConversionTable.csv, M365Licenses.json,
# ExcludeSkuList.JSON) from Microsoft's published CSV and opens a PR against dev when
# anything changed. Requires "Allow GitHub Actions to create and approve pull requests"
# in the repository's Actions settings.
on:
schedule:
- cron: '0 6 * * *' # Daily 06:00 UTC
workflow_dispatch:
permissions:
contents: write
pull-requests: write
concurrency:
group: update-license-skus
cancel-in-progress: false
jobs:
update:
if: github.repository == 'CyberDrain/CIPP'
name: Update license SKUs
# ubuntu on purpose: the generated files are stored with LF line endings, which is
# exactly what PowerShell writes here. A Windows runner would work too, but only
# because autocrlf normalises on commit - this way there is nothing to normalise.
runs-on: ubuntu-latest
env:
BASE_BRANCH: dev
BRANCH_PREFIX: chore/license-sku-update
GH_TOKEN: ${{ github.token }}
steps:
- name: Checkout
uses: actions/checkout@v4
with:
ref: dev
# Full history: pushing a new branch from a shallow clone is rejected.
fetch-depth: 0
- name: Clean up temp branches from earlier runs
shell: pwsh
run: |
# A prefixed branch with no open PR has been merged, closed, or orphaned.
# Best effort - a failure here must not block today's update.
$Branches = git ls-remote --heads origin |
ForEach-Object { ($_ -split "`t")[1] -replace '^refs/heads/', '' } |
Where-Object { $_ -like "$env:BRANCH_PREFIX-*" }
foreach ($Branch in $Branches) {
$OpenPrs = @(gh pr list --head $Branch --state open --json number | ConvertFrom-Json)
if ($OpenPrs.Count -eq 0) {
Write-Host "Deleting merged/closed branch $Branch"
git push origin --delete $Branch
if ($LASTEXITCODE -ne 0) { Write-Warning "Could not delete $Branch." }
} else {
Write-Host "Keeping $Branch (PR #$($OpenPrs[0].number) is still open)"
}
}
exit 0
- name: Update license SKU files
shell: pwsh
run: |
$ErrorActionPreference = 'Stop'
# Snapshot the current data so the PR body can say what actually changed.
Copy-Item ./backend/Config/ConversionTable.csv "$env:RUNNER_TEMP/ConversionTable.before.csv"
./build/tools/Update-LicenseSKUFiles.ps1
- name: Validate the downloaded data
shell: pwsh
run: |
$ErrorActionPreference = 'Stop'
# That download URL has served error pages and truncated files before now.
# Never open a PR on top of one.
$Csv = Import-Csv -Path ./backend/Config/ConversionTable.csv
if ($Csv.Count -lt 1000) {
throw "ConversionTable.csv has only $($Csv.Count) rows - refusing to continue."
}
$Columns = $Csv[0].PSObject.Properties.Name
foreach ($Column in 'Product_Display_Name', 'String_Id', 'GUID', 'Service_Plan_Name', 'Service_Plan_Id') {
if ($Column -notin $Columns) { throw "ConversionTable.csv is missing the $Column column." }
}
$Json = Get-Content -Path ./frontend/src/data/M365Licenses.json -Raw | ConvertFrom-Json
if ($Json.Count -ne $Csv.Count) {
throw "M365Licenses.json ($($Json.Count) entries) and ConversionTable.csv ($($Csv.Count) rows) disagree."
}
Write-Host "Validated $($Csv.Count) license SKU rows."
- name: Stage changes
id: stage
shell: pwsh
run: |
$ErrorActionPreference = 'Stop'
git add -- ':(glob)backend/**/*ConversionTable.csv' 'frontend/src/data/M365Licenses.json' 'backend/Config/ExcludeSkuList.JSON'
$Staged = @(git diff --cached --name-only)
if ($Staged.Count -eq 0) {
Write-Host 'License SKU data is already up to date.'
'changed=false' | Out-File -FilePath $env:GITHUB_OUTPUT -Append
exit 0
}
Write-Host "Changed files:`n$($Staged -join "`n")"
'changed=true' | Out-File -FilePath $env:GITHUB_OUTPUT -Append
- name: Build the PR body
if: steps.stage.outputs.changed == 'true'
shell: pwsh
run: |
$ErrorActionPreference = 'Stop'
$Before = Import-Csv -Path "$env:RUNNER_TEMP/ConversionTable.before.csv"
$After = Import-Csv -Path ./backend/Config/ConversionTable.csv
# SKUs, not rows: the CSV carries one row per service plan within a SKU.
$BeforeSkus = @{}
foreach ($Row in $Before) { $BeforeSkus[$Row.GUID] = $Row.Product_Display_Name }
$AfterSkus = @{}
foreach ($Row in $After) { $AfterSkus[$Row.GUID] = $Row.Product_Display_Name }
$Added = $AfterSkus.Keys | Where-Object { -not $BeforeSkus.ContainsKey($_) } | Sort-Object { $AfterSkus[$_] }
$Removed = $BeforeSkus.Keys | Where-Object { -not $AfterSkus.ContainsKey($_) } | Sort-Object { $BeforeSkus[$_] }
$Renamed = $AfterSkus.Keys |
Where-Object { $BeforeSkus.ContainsKey($_) -and $BeforeSkus[$_] -cne $AfterSkus[$_] } |
Sort-Object { $AfterSkus[$_] }
function Format-SkuList {
param($Guids, $Names, $Heading, $Previous)
$Count = @($Guids).Count
if ($Count -eq 0) { return @() }
$Lines = @('', "### $Heading ($Count)", '')
$Lines += @($Guids) | Select-Object -First 25 | ForEach-Object {
if ($Previous) { '- {0} -> {1} `{2}`' -f $Previous[$_], $Names[$_], $_ }
else { '- {0} `{1}`' -f $Names[$_], $_ }
}
if ($Count -gt 25) { $Lines += '- ...and {0} more' -f ($Count - 25) }
return $Lines
}
$Body = @(
'Automated refresh of the Microsoft license SKU data, generated by `build/tools/Update-LicenseSKUFiles.ps1` from [Microsoft''s published product names and service plan identifiers CSV](https://learn.microsoft.com/entra/identity/users/licensing-service-plan-reference).'
''
'**Service plan rows:** {0:N0} -> {1:N0}' -f $Before.Count, $After.Count
'**Distinct SKUs:** {0:N0} -> {1:N0}' -f $BeforeSkus.Count, $AfterSkus.Count
)
$Body += Format-SkuList -Guids $Added -Names $AfterSkus -Heading 'New SKUs'
$Body += Format-SkuList -Guids $Removed -Names $BeforeSkus -Heading 'Removed SKUs'
$Body += Format-SkuList -Guids $Renamed -Names $AfterSkus -Previous $BeforeSkus -Heading 'Renamed SKUs'
$Body += @(
''
'<details><summary>Files changed</summary>'
''
'```'
(git diff --cached --stat)
'```'
''
'</details>'
''
'---'
''
'_Opened by the [Update License SKU Data]({0}/{1}/actions/runs/{2}) workflow. The `{3}-*` branch is deleted on the next run once this PR is merged or closed._' -f $env:GITHUB_SERVER_URL, $env:GITHUB_REPOSITORY, $env:GITHUB_RUN_ID, $env:BRANCH_PREFIX
)
$Body -join "`n" | Set-Content -Path "$env:RUNNER_TEMP/pr-body.md" -Encoding utf8
- name: Commit, push and open the PR
if: steps.stage.outputs.changed == 'true'
shell: pwsh
run: |
$ErrorActionPreference = 'Stop'
$PSNativeCommandUseErrorActionPreference = $true
# Reuse the branch of an already-open PR so a run of daily updates does not
# stack up one PR per day; otherwise cut a fresh dated branch.
$OpenPrs = @(gh pr list --state open --base $env:BASE_BRANCH --limit 100 --json number,headRefName | ConvertFrom-Json)
$Existing = $OpenPrs | Where-Object { $_.headRefName -like "$env:BRANCH_PREFIX-*" } | Select-Object -First 1
if ($Existing) {
$Branch = $Existing.headRefName
Write-Host "Updating PR #$($Existing.number) on $Branch"
} else {
$Branch = '{0}-{1}' -f $env:BRANCH_PREFIX, (Get-Date -Format 'yyyyMMdd')
Write-Host "Creating $Branch"
}
git config user.name 'github-actions[bot]'
git config user.email 'github-actions[bot]@users.noreply.github.com'
git switch -c $Branch
git commit -m 'chore(licenses): update Microsoft license SKU data'
# Force: the branch is disposable and always one commit on top of dev.
git push --force origin $Branch
if (-not $Existing) {
gh pr create `
--base $env:BASE_BRANCH `
--head $Branch `
--title ('chore(licenses): update Microsoft license SKU data ({0})' -f (Get-Date -Format 'yyyy-MM-dd')) `
--body-file "$env:RUNNER_TEMP/pr-body.md"
} else {
gh pr edit $Existing.number --body-file "$env:RUNNER_TEMP/pr-body.md"
}