Update License SKU Data #7
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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" | |
| } |