Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Only pass `MajorWithMinorVersions` when the request targets existing document
libraries, per the `Set-PnPSiteVersionPolicy` constraint (it is rejected for a
new-libraries-only request when auto expiration is off)
- Add `SiteScope` (`Selected` default, or `All`), `TenantAdminUrl` and `SiteFilter`
properties: `SiteScope: All` enumerates every site collection via
`Get-PnPTenantSite` (OneDrive excluded, optional server-side filter) and applies
the version policy across the tenant, still gated by the per-site drift check.
`SiteUrls` is optional for `All` (requires `TenantAdminUrl`); `All` is not supported
with the `Legacy` mode
- Add a warning about the app-only (Azure Automation / Managed Identity) limitation
of `Get-`/`Set-PnPSiteVersionPolicy`, which require a delegated site-collection-admin
context
- SPSCleanVersions.Tests.ps1
- Add tests for the site version policy modes, `ApplyTo` mapping, and functional
contexts validating the `ExpireVersionsAfterDays` rules and the drift comparison
Expand Down
46 changes: 9 additions & 37 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,44 +4,16 @@
![Total downloads](https://img.shields.io/github/downloads/luigilink/SPSCleanVersions/total.svg?style=flat)
![Issues opened](https://img.shields.io/github/issues/luigilink/SPSCleanVersions.svg?style=flat)
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
[![Contributor Covenant](https://img.shields.io/badge/Contributor%20Covenant-2.1-4baaaa.svg)](code_of_conduct.md)
[![Contributor Covenant](https://img.shields.io/badge/Contributor%20Covenant-2.1-4baaaa.svg)](CODE_OF_CONDUCT.md)
[![SPSCleanVersions CI Pester Tests](https://github.com/luigilink/SPSCleanVersions/actions/workflows/pester.yml/badge.svg)](https://github.com/luigilink/SPSCleanVersions/actions/workflows/pester.yml)

## Description
**SPSCleanVersions** is a PowerShell tool that manages version history across your SharePoint Online tenant to optimize storage costs. It sets per-library major/minor version limits, applies site-level version policies (including expiration) via `Set-PnPSiteVersionPolicy`, and can act on selected sites or the whole tenant — only where a change is actually needed. Compatible with local execution and Azure Automation Runbooks.

SPSCleanVersions is a PowerShell script tool to clean Version History in your SharePoint Tenant. Optimize your storage costs by managing major and minor versions across libraries and lists.
## Quick links

[**Download the latest release here!**](https://github.com/luigilink/SPSCleanVersions/releases/latest)

## Key Features

* **Two Config Sources:** Pass configuration inline via `-InputJson` (ideal for Azure Automation Runbooks) or from a local JSON file via `-ConfigFile` (ideal for local execution and testing). Both share the same JSON schema and validation.
* **Simulation Mode:** Use `"DryRun": true` in the JSON (or `-WhatIf` locally) to preview changes safely.
* **Flexible Retention:** Define custom thresholds for major and minor versions.
* **Site Version Policy:** Set `"VersionPolicyMode"` to `AutoExpiration`, `ExpireAfter`, `NoExpiration` or `InheritFromTenant` to apply a site-level policy via `Set-PnPSiteVersionPolicy` — including version expiration (`ExpireVersionsAfterDays`) and applying to new and/or existing libraries. The default `Legacy` mode keeps the per-library count-based behaviour.
* **Multi-Site Processing:** Pass multiple Site Collection URLs in the `SiteUrls` JSON array to process them in a single execution.
* **Force Delete Old Versions:** Set `"ForceDeleteOldVersions": true` to trigger a batch delete job via `New-PnPSiteFileVersionBatchDeleteJob`. Requires delegated user context (automatically skipped in Azure Automation).
* **Azure Automation Ready:** Single string parameter avoids all runbook type limitations (arrays, switches, booleans).

## Requirements

### PowerShell 7.2+ (Core)

Requires PowerShell 7.2 or later with PSEdition Core. [Installation guide](https://learn.microsoft.com/en-us/powershell/scripting/install/install-powershell?view=powershell-7.5).

### Module PnP.PowerShell (>= 2.12.0)

This tool relies on the PnP.PowerShell module version 2.12.0 or later. [Installation guide](https://pnp.github.io/powershell/articles/installation.html).

### Permissions

* **Role:** SharePoint Administrator or Global Administrator.
* **API Permissions:** `Sites.FullControl.All` (when using App Registration).

## Documentation

For detailed usage, configuration, and getting started information, visit the [SPSCleanVersions Wiki](https://github.com/luigilink/SPSCleanVersions/wiki)

## Changelog

A full list of changes in each version can be found in the [change log](CHANGELOG.md)
- 📦 [Latest release](https://github.com/luigilink/SPSCleanVersions/releases/latest)
- 📖 [Documentation (Wiki)](https://github.com/luigilink/SPSCleanVersions/wiki)
- 🚀 [Getting Started](https://github.com/luigilink/SPSCleanVersions/wiki/Getting-Started)
- ⚙️ [Configuration reference](https://github.com/luigilink/SPSCleanVersions/wiki/Configuration)
- 📝 [Changelog](CHANGELOG.md)
- 🤝 [Contributing](.github/CONTRIBUTING.md)
5 changes: 5 additions & 0 deletions RELEASE-NOTES.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,11 @@
policy is treated as a drift and applied
- Only pass `MajorWithMinorVersions` when the request targets existing document
libraries, per the `Set-PnPSiteVersionPolicy` constraint
- Add `SiteScope` (`Selected` default, or `All`), `TenantAdminUrl` and `SiteFilter`:
`SiteScope: All` enumerates every site collection via `Get-PnPTenantSite` and applies
the version policy across the tenant, still gated by the per-site drift check
- Add a warning about the app-only (Azure Automation / Managed Identity) limitation of
`Get-`/`Set-PnPSiteVersionPolicy`
- Wiki Documentation
- Document `VersionPolicyMode`, `ExpireVersionsAfterDays`, `ApplyTo` and the
drift-based apply behaviour
Expand Down
105 changes: 99 additions & 6 deletions scripts/SPSCleanVersions.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,13 @@
'NoExpiration' forces 0.
- ApplyTo (string, optional, default: 'Both') — 'New', 'Existing' or 'Both'
document libraries (site version policy modes only).
- SiteScope (string, optional, default: 'Selected') — 'Selected' processes
SiteUrls; 'All' enumerates every site collection via Get-PnPTenantSite
(site version policy modes only; requires TenantAdminUrl).
- TenantAdminUrl (string, optional) — SharePoint admin center URL, required when
SiteScope is 'All' (e.g. https://contoso-admin.sharepoint.com).
- SiteFilter (string, optional) — server-side -Filter passed to Get-PnPTenantSite
to narrow the enumeration when SiteScope is 'All'.

.PARAMETER ConfigFile
Path to a local JSON file containing the same configuration schema as -InputJson.
Expand Down Expand Up @@ -153,13 +160,35 @@ if ($config -isnot [System.Management.Automation.PSCustomObject]) {
"Remove any surrounding single quotes or curly/smart quotes and paste the raw JSON object."
}

# Required: SiteUrls
if (-not $config.PSObject.Properties['SiteUrls'] -or
$null -eq $config.SiteUrls -or
@($config.SiteUrls).Count -eq 0) {
throw "JSON property 'SiteUrls' is required and must contain at least one URL."
# Site scope: 'Selected' processes the explicit SiteUrls; 'All' enumerates every site
# collection in the tenant via Get-PnPTenantSite (requires TenantAdminUrl).
$validScopes = @('Selected', 'All')
[string]$SiteScope = if ($config.PSObject.Properties['SiteScope']) { [string]$config.SiteScope } else { 'Selected' }
$matchedScope = $validScopes | Where-Object { $_ -ieq $SiteScope }
if (-not $matchedScope) {
throw "Invalid 'SiteScope' value '$SiteScope'. Allowed values: $($validScopes -join ', ')."
}
$SiteScope = $matchedScope

[string]$TenantAdminUrl = if ($config.PSObject.Properties['TenantAdminUrl']) { [string]$config.TenantAdminUrl } else { '' }
[string]$SiteFilter = if ($config.PSObject.Properties['SiteFilter']) { [string]$config.SiteFilter } else { '' }

# SiteUrls is required for 'Selected' scope; for 'All' it is optional (sites are
# enumerated from the tenant) and TenantAdminUrl becomes required instead.
if ($SiteScope -eq 'Selected') {
if (-not $config.PSObject.Properties['SiteUrls'] -or
$null -eq $config.SiteUrls -or
@($config.SiteUrls).Count -eq 0) {
throw "JSON property 'SiteUrls' is required and must contain at least one URL (or set 'SiteScope' to 'All')."
}
[string[]]$SiteUrls = @($config.SiteUrls)
}
else {
if ([string]::IsNullOrWhiteSpace($TenantAdminUrl)) {
throw "JSON property 'TenantAdminUrl' is required when 'SiteScope' is 'All' (e.g. https://contoso-admin.sharepoint.com)."
}
[string[]]$SiteUrls = @()
}
[string[]]$SiteUrls = @($config.SiteUrls)

# Optional with defaults
[int]$KeepMajorVersions = if ($config.PSObject.Properties['KeepMajorVersions']) { $config.KeepMajorVersions } else { 50 }
Expand Down Expand Up @@ -197,6 +226,12 @@ if ($ExpireVersionsAfterDays -ne 0 -and $ExpireVersionsAfterDays -lt 30) {
if ($VersionPolicyMode -eq 'ExpireAfter' -and $ExpireVersionsAfterDays -lt 30) {
throw "VersionPolicyMode 'ExpireAfter' requires 'ExpireVersionsAfterDays' to be greater than or equal to 30."
}

# 'SiteScope: All' only makes sense for the site version policy modes; the Legacy
# per-library path relies on an explicit SiteUrls list.
if ($SiteScope -eq 'All' -and $VersionPolicyMode -eq 'Legacy') {
throw "'SiteScope' = 'All' is only supported with the site version policy modes (VersionPolicyMode: AutoExpiration, ExpireAfter, NoExpiration or InheritFromTenant), not 'Legacy'."
}
#endregion

# When DryRun is specified, enable WhatIf mode so that ShouldProcess calls are simulated.
Expand Down Expand Up @@ -362,6 +397,64 @@ function Set-SiteVersionPolicy {
}
}

function Get-TenantSiteUrls {
<#
.SYNOPSIS
Connects to the tenant admin center and returns the URLs of all site collections
(OneDrive excluded), optionally narrowed by a server-side filter.
#>
[CmdletBinding()]
[OutputType([string[]])]
param
(
[Parameter(Mandatory = $true)] [string] $AdminUrl,
[Parameter()] [string] $Filter = '',
[Parameter()] [string] $ClientId = ''
)

Write-Output "Connecting to tenant admin center: $AdminUrl ..."
if (Test-IsAzureAutomation) {
if (-not [string]::IsNullOrEmpty($ClientId)) {
Connect-PnPOnline -Url $AdminUrl -ManagedIdentity -ClientId $ClientId
}
else {
Connect-PnPOnline -Url $AdminUrl -ManagedIdentity
}
}
else {
Connect-PnPOnline -Url $AdminUrl -Interactive -ClientId $ClientId
}

try {
$getParams = @{ ErrorAction = 'Stop' }
if (-not [string]::IsNullOrWhiteSpace($Filter)) { $getParams['Filter'] = $Filter }
$sites = Get-PnPTenantSite @getParams
$urls = @($sites | Where-Object { $null -ne $_.Url } | Select-Object -ExpandProperty Url)
Write-Output "Discovered $($urls.Count) site collection(s) from the tenant."
return $urls
}
finally {
Disconnect-PnPOnline
}
}

# Resolve the list of sites to process. For 'All' scope, enumerate the tenant first.
if ($SiteScope -eq 'All') {
Write-Output "--- SiteScope=All: enumerating tenant site collections ---"
if ($WhatIfPreference) {
Write-Warning "SiteScope=All applies the version policy across the whole tenant. Review the DryRun output carefully before a real run."
}
try {
$SiteUrls = Get-TenantSiteUrls -AdminUrl $TenantAdminUrl -Filter $SiteFilter -ClientId $ClientId
}
catch {
throw "Failed to enumerate tenant sites from ${TenantAdminUrl}: $($_.Exception.Message)"
}
if (@($SiteUrls).Count -eq 0) {
Write-Warning "No site collections were returned from the tenant; nothing to process."
}
}

foreach ($SiteUrl in $SiteUrls) {
Write-Output "Processing Site: $SiteUrl"

Expand Down
29 changes: 28 additions & 1 deletion tests/SPSCleanVersions.Tests.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -372,7 +372,6 @@ Describe 'SPSCleanVersions Script' {
It 'Should define the Set-SiteVersionPolicy helper function' {
$scriptContent | Should -Match 'function\s+Set-SiteVersionPolicy'
}

It 'Should call Set-PnPSiteVersionPolicy' {
$scriptContent | Should -Match 'Set-PnPSiteVersionPolicy'
}
Expand Down Expand Up @@ -474,6 +473,34 @@ Describe 'SPSCleanVersions Script' {
$scriptContent | Should -Match 'require a delegated user context'
}

It 'Should define the Get-TenantSiteUrls helper function' {
$scriptContent | Should -Match 'function\s+Get-TenantSiteUrls'
}

It 'Should enumerate tenant sites with Get-PnPTenantSite when SiteScope is All' {
$scriptContent | Should -Match 'Get-PnPTenantSite'
}

It 'Should default SiteScope to Selected' {
$scriptContent | Should -Match "SiteScope.*'Selected'"
}

It 'Should require TenantAdminUrl when SiteScope is All' {
$scriptContent | Should -Match "'TenantAdminUrl' is required when 'SiteScope' is 'All'"
}

It 'Should reject SiteScope All with Legacy mode' {
$scriptContent | Should -Match "'SiteScope' = 'All' is only supported"
}

It 'Should make SiteUrls optional when SiteScope is All' {
$scriptContent | Should -Match "or set 'SiteScope' to 'All'"
}

It 'Should support a server-side SiteFilter for enumeration' {
$scriptContent | Should -Match 'SiteFilter'
}

Context 'Drift comparison (functional, real field shapes)' {

BeforeAll {
Expand Down
29 changes: 27 additions & 2 deletions wiki/Configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,15 +21,18 @@ Both sources are parsed with `ConvertFrom-Json` and share the exact same schema,
"DryRun": <boolean>,
"VersionPolicyMode": "<string>",
"ExpireVersionsAfterDays": <integer>,
"ApplyTo": "<string>"
"ApplyTo": "<string>",
"SiteScope": "<string>",
"TenantAdminUrl": "<string>",
"SiteFilter": "<string>"
}
```

## Properties

| Property | Type | Required | Default | Description |
|---|---|---|---|---|
| `SiteUrls` | string[] | **Yes** | — | One or more SharePoint Site Collection URLs to process. |
| `SiteUrls` | string[] | Conditional | — | One or more SharePoint Site Collection URLs to process. **Required** when `SiteScope` is `Selected` (default); optional when `SiteScope` is `All`. |
| `KeepMajorVersions` | integer | No | `50` | Maximum number of major versions to retain. Maps to `-MajorVersions` in the site version policy modes. |
| `KeepMinorVersions` | integer | No | `0` | Maximum number of minor versions to retain. Set to `0` to disable minor versioning. Maps to `-MajorWithMinorVersions` in the site version policy modes. |
| `ClientId` | string | No | — | Azure AD App Registration Client ID used for authentication. Required for Interactive login (local) and optional for Managed Identity (Azure Automation). |
Expand All @@ -38,6 +41,9 @@ Both sources are parsed with `ConvertFrom-Json` and share the exact same schema,
| `VersionPolicyMode` | string | No | `Legacy` | Version policy mechanism. `Legacy` keeps the per-library count-based `Set-PnPList` behaviour. `AutoExpiration`, `ExpireAfter`, `NoExpiration` and `InheritFromTenant` apply a site-level policy via `Set-PnPSiteVersionPolicy`. See [Version policy modes](#version-policy-modes). |
| `ExpireVersionsAfterDays` | integer | No | `0` | Number of days after which versions expire. Used by `ExpireAfter` (must be **>= 30**). `NoExpiration` forces `0`. |
| `ApplyTo` | string | No | `Both` | `New`, `Existing` or `Both` document libraries. Maps to `-ApplyToNewDocumentLibraries` / `-ApplyToExistingDocumentLibraries`. Site version policy modes only. |
| `SiteScope` | string | No | `Selected` | `Selected` processes `SiteUrls`. `All` enumerates **every** site collection in the tenant via `Get-PnPTenantSite`. Site version policy modes only (not `Legacy`). See [Tenant-wide scope](#tenant-wide-scope-sitescope-all). |
| `TenantAdminUrl` | string | Conditional | — | SharePoint admin center URL (e.g. `https://contoso-admin.sharepoint.com`). **Required** when `SiteScope` is `All`. |
| `SiteFilter` | string | No | — | Optional server-side `-Filter` passed to `Get-PnPTenantSite` to narrow the enumeration when `SiteScope` is `All` (e.g. `"Url -like 'sales'"`). |

## Version policy modes

Expand All @@ -57,6 +63,25 @@ Both sources are parsed with `ConvertFrom-Json` and share the exact same schema,

> **⚠️ Azure Automation / app-only limitation:** `Get-PnPSiteVersionPolicy` and `Set-PnPSiteVersionPolicy` require a **delegated user context** that is **site collection administrator**. When running as an Azure Automation Runbook with a **Managed Identity** (app-only), there is no user context, so the site version policy modes may fail with an *"Attempted to perform an unauthorized operation"* error. The script emits a warning in that case. For tenant-wide version policy automation from a runbook, prefer the **tenant-level** settings (`Set-PnPTenant`) or run the site version policy modes **interactively / locally** with a SharePoint Administrator account. The default `Legacy` mode is unaffected.

## Tenant-wide scope (SiteScope: All)

By default (`SiteScope: Selected`) the script only processes the sites listed in `SiteUrls`. Set `SiteScope: All` to apply a site version policy across **every site collection in the tenant**. The script connects to `TenantAdminUrl`, enumerates sites with `Get-PnPTenantSite` (OneDrive personal sites excluded), optionally narrowed by `SiteFilter`, and applies the policy to each — still gated by the per-site drift check, so unchanged sites are skipped.

> **⚠️ Tenant-wide impact:** `SiteScope: All` can touch **thousands** of site collections and, when `ApplyTo` includes `Existing`, submit a background version-trim job on each. **Always run with `"DryRun": true` first** to review the scope, and consider narrowing with `SiteFilter`. This scope is only supported with the site version policy modes (not `Legacy`), and requires **SharePoint Administrator** privileges plus a delegated (interactive/local) context.

```json
{
"SiteScope": "All",
"TenantAdminUrl": "https://contoso-admin.sharepoint.com",
"SiteFilter": "Url -like 'sales'",
"VersionPolicyMode": "ExpireAfter",
"ExpireVersionsAfterDays": 180,
"KeepMajorVersions": 100,
"ApplyTo": "Both",
"DryRun": true
}
```

## Examples

### Apply an ExpireAfter site version policy
Expand Down
10 changes: 10 additions & 0 deletions wiki/Usage.md
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,16 @@ Set `"VersionPolicyMode"` to a site-level mode to apply a policy via `Set-PnPSit

> **Note:** `ExpireVersionsAfterDays` must be `0` (no expiration) or `>= 30`. See [Configuration](./Configuration#version-policy-modes) for all modes.

### Example 6: Apply a policy tenant-wide (SiteScope: All)

Set `"SiteScope": "All"` with a `TenantAdminUrl` to enumerate every site collection via `Get-PnPTenantSite` and apply the policy across the tenant (only where a drift is detected). Always dry-run first.

```powershell
.\SPSCleanVersions.ps1 -InputJson '{"SiteScope":"All","TenantAdminUrl":"https://contoso-admin.sharepoint.com","VersionPolicyMode":"ExpireAfter","ExpireVersionsAfterDays":180,"KeepMajorVersions":100,"DryRun":true}'
```

> **⚠️ Warning:** `SiteScope: All` can touch thousands of sites. Run with `"DryRun": true` first and consider narrowing with `SiteFilter`. See [Tenant-wide scope](./Configuration#tenant-wide-scope-sitescope-all).

## Error Handling

Ensure the provided credentials have access to the SharePoint Sites.
Expand Down
15 changes: 15 additions & 0 deletions wiki/_Sidebar.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
**Navigation**

- [🏠 Home](Home)
- [🚀 Getting Started](Getting-Started)
- [⚙️ Configuration](Configuration)
- [📖 Usage](Usage)

---

**Project**

- [Repository](https://github.com/luigilink/SPSCleanVersions)
- [Releases](https://github.com/luigilink/SPSCleanVersions/releases)
- [Issues](https://github.com/luigilink/SPSCleanVersions/issues)
- [Changelog](https://github.com/luigilink/SPSCleanVersions/blob/main/CHANGELOG.md)