From 55fa5bcdc03fef6d6962ce94a439ac3aa548844d Mon Sep 17 00:00:00 2001 From: Audun Akse Date: Sun, 17 Nov 2024 10:31:04 +0100 Subject: [PATCH] init --- .github/dependabot.yaml | 11 ++ .github/workflows/.test.invoke-pester.yaml | 16 ++ .github/workflows/release-version.yaml | 43 +++++ README.md | 201 +++++++++++++++++++++ action.yaml | 140 ++++++++++++++ src/Delete-Files.ps1 | 115 ++++++++++++ src/tests/Delete-Files.tests.ps1 | 90 +++++++++ 7 files changed, 616 insertions(+) create mode 100644 .github/dependabot.yaml create mode 100644 .github/workflows/.test.invoke-pester.yaml create mode 100644 .github/workflows/release-version.yaml create mode 100644 README.md create mode 100644 action.yaml create mode 100644 src/Delete-Files.ps1 create mode 100644 src/tests/Delete-Files.tests.ps1 diff --git a/.github/dependabot.yaml b/.github/dependabot.yaml new file mode 100644 index 0000000..a10abab --- /dev/null +++ b/.github/dependabot.yaml @@ -0,0 +1,11 @@ +# To get started with Dependabot version updates, you'll need to specify which +# package ecosystems to update and where the package manifests are located. +# Please see the documentation for all configuration options: +# https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates + +version: 2 +updates: + - package-ecosystem: "github-actions" # See documentation for possible values + directory: "/" # Location of package manifests + schedule: + interval: weekly diff --git a/.github/workflows/.test.invoke-pester.yaml b/.github/workflows/.test.invoke-pester.yaml new file mode 100644 index 0000000..8eb74ea --- /dev/null +++ b/.github/workflows/.test.invoke-pester.yaml @@ -0,0 +1,16 @@ +name: Invoke-Pester + +on: + workflow_dispatch: + pull_request: + +jobs: + run-tests: + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Run Pester tests + shell: pwsh + run: Invoke-Pester -Output Detailed diff --git a/.github/workflows/release-version.yaml b/.github/workflows/release-version.yaml new file mode 100644 index 0000000..385fee0 --- /dev/null +++ b/.github/workflows/release-version.yaml @@ -0,0 +1,43 @@ +# File: .github/workflows/release-version.yaml +name: Create versioned release + +permissions: + contents: write + packages: write + +on: + workflow_dispatch: + inputs: + update-type: + type: choice + description: Which version you want to increment? Use major, minor or patch + required: true + default: patch + options: + - major + - minor + - patch + label: + description: Add Labels. i.e final, alpha, rc + required: false + pre-release: + type: boolean + description: Pre-release + required: false + default: false + +jobs: + release-version: + name: Create SemVer releases + runs-on: ubuntu-latest + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Create version releases + uses: climpr/semver-release@v1 + with: + update-type: ${{ github.event.inputs.update-type }} + label: ${{ github.event.inputs.label }} + pre-release: ${{ github.event.inputs.pre-release }} diff --git a/README.md b/README.md new file mode 100644 index 0000000..131ea8e --- /dev/null +++ b/README.md @@ -0,0 +1,201 @@ +# Update Landing Zone Repository + + + +- [Update Landing Zone Repository](#update-landing-zone-repository) + - [How to use this action](#how-to-use-this-action) + - [Prerequisites](#prerequisites) + - [`repoSource` strategy (Preferred)](#reposource-strategy-preferred) + - [`repoTemplate` strategy](#repotemplate-strategy) + - [delete-files.json](#delete-filesjson) + - [File schema](#file-schema) + - [Generating file hashes](#generating-file-hashes) + - [Example](#example) + - [Parameters](#parameters) + - [`landing-zone-path`](#landing-zone-path) + - [`repo-sources-path`](#repo-sources-path) + - [`github-token`](#github-token) + - [Outputs](#outputs) + - [`deleted-files`](#deleted-files) + - [`deleted-directories`](#deleted-directories) + + + +This action updates files in Landing Zone Repositories. + +It can be used with two strategies: + +1. `repoSources` (Preferred) source directories. +2. `repoTemplate` GitHub template repository. + +It will sync all files in the respective source to the target Landing Zone repository. +In addition, it will delete any files located in an `delete-files.json` file containing file paths and file hashes. + +## How to use this action + +To use this action, implement the steps as shown below in your workflow. + +```yaml +# ... +permissions: read + +steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Get GH Token + id: gh-app-token + uses: actions/create-github-app-token@v1 + with: + app-id: ${{ vars.GH_APP_ID }} + private-key: ${{ secrets.GH_APP_PRIVATE_KEY }} + owner: ${{ github.repository_owner }} + + - name: Update Landing Zone Repository + uses: climpr/update-landing-zone-repository@v1 + with: + landing-zone-path: ${{ path-to-landing-zone-dir }} + repo-sources-path: lz-management/repo-sources + github-token: ${{ steps.gh-app-token.outputs.token }} +# ... +``` + +## Prerequisites + +- If the Landing Zone is configured to use the `repoSource` strategy, it requires the repository to be checked out first. +- A GitHub token that has `Read and write` permissions `Contents` on the target Landing Zone repository. +- A `delete-files.json` configuration file for specifying which files to delete. More on this file in a separate chapter. + +The target Landing Zone can be configured with either of the two strategies. The prerequisites for the strategies are as follows: + +### `repoSource` strategy (Preferred) + +- A `repo-sources` directory in the repository running this action. +- A `source` subdirectory in the `repo-sources` directory corresponding to the `repoSources.source` property in the Landing Zone configuration file. +- The named `source` directory must contain a `contents` subdirectory containing the source files to copy. +- The named `source` directory must contain a `delete-files.json` file, specifying which files to delete. More on this file in a separate chapter. + +> [!TIP] +> The `repo-sources` directory can contain multiple `source` subdirectories to support different repositories having different sources or versioning sources. + +The file structure must be as follows: + +``` +../ + repo-sources/ + / + delete-files.json + contents/ + ... + ... + / + delete-files.json + contents/ + ... +``` + +### `repoTemplate` strategy + +As opposed to the `repoSource` strategy, the `repoTemplate` + +- A GitHub repository corresponding to the `repoTemplate` property in the Landing Zone configuration file. +- The GitHub template repository must be configured as a template repository in GitHub. +- The GitHub repository must contain the source files for the update operation. +- The GitHub repository must contain a `delete-files.json` file in the root directory. + +The file structure must be as follows: + +``` +/ + delete-files.json + ... + ... +``` + +## delete-files.json + +This action requires you to create a `delete-files.json` configuration file. +As the target repositories will contain files that are not part of the source directories or repositories, we cannot know which files to delete without explicitly configuring them. +The `delete-files.json` file is used to specify which files to delete and which files to skip. + +To create this file, start with an empty file and add the following content: + +```json +{ + "$schema": "https://raw.githubusercontent.com/climpr/climpr-schemas/main/schemas/v1.0.0/lz-management/delete-files.json#" +} +``` + +This will ensure you have auto-complete and validation for the configuration file. + +### File schema + +An example file looks like this: + +> [!TIP] +> You can use either the `hash` property for a single file hash, or `hashes` property for a list of file hashes. This can be useful if you want to support multiple versions of the same file. + +```jsonc +{ + "$schema": "https://raw.githubusercontent.com/climpr/climpr-schemas/main/schemas/v1.0.0/lz-management/delete-files.json#", + // A list of directory relative paths that should be excluded from processing. + "directoriesToExclude": [ + ".git" // The '.git', 'delete-files.json' and 'delete-files.jsonc' directory and files are always excluded. + ], + // A list of directory relative paths that should be deleted. + "filesToDelete": [ + { + "path": "string", // Relative path to file + "hash": "3A888546831AE05A0EC1D040DE396262284E4B4FC0066A00D56016BF3955C90E" // File hash + }, + { + "path": "string", // Relative path to file + "hashes": [ + // List of file hashes + "3A888546831AE05A0EC1D040DE396262284E4B4FC0066A00D56016BF3955C90E" + ] + } + ] +} +``` + +### Generating file hashes + +Generating file hashes is done by using the `Get-FileHash` command in PowerShell. + +#### Example + +In this example, the file hash is the string under `Hash` below. + +```powershell +Get-FileHash "./directory/file.txt" + +# Result +# Algorithm Hash Path +# --------- ---- ---- +# SHA256 E3B0C44298FC1C149AFBF4C8996FB92427AE41E4649B934CA495991B7852B855 /directory/file.txt +``` + +## Parameters + +### `landing-zone-path` + +(Required) Path to the Landing Zone directory. + +### `repo-sources-path` + +(Required) Path to the 'repo-sources' directory. + +### `github-token` + +(Required) The token for the GitHub app that is allowed to create and update repositories in the organization. + +## Outputs + +### `deleted-files` + +A JSON list of the deleted files relative to the 'path' input parameter. + +### `deleted-directories` + +A JSON list of the deleted directories relative to the 'path' input parameter. diff --git a/action.yaml b/action.yaml new file mode 100644 index 0000000..8311267 --- /dev/null +++ b/action.yaml @@ -0,0 +1,140 @@ +name: Update Landing Zone Repository +description: Updates the files in a Landing Zone repository. + +inputs: + landing-zone-path: + description: Path to the Landing Zone directory. + required: true + + repo-sources-path: + description: Path to the 'repo-sources' directory. + required: true + + github-token: + description: The token for the GitHub app that is allowed to create and update repositories in the organization. + required: true + +outputs: + deleted-files: + description: A JSON list of the deleted files relative to the 'path' input parameter. + value: ${{ steps.delete-files.outputs.deleted-files }} + + deleted-directories: + description: A JSON list of the deleted directories relative to the 'path' input parameter. + value: ${{ steps.delete-files.outputs.deleted-directories }} + +runs: + using: composite + steps: + #* Get Landing Zone configuration + - name: Get Landing Zone Repository + id: lz-config + shell: pwsh + env: + landingZonePath: ${{ inputs.landing-zone-path }} + repoSourcesPath: ${{ inputs.repo-sources-path }} + run: | + #* Get Landing Zone configuration + $lzConfig = Get-Content -Path "$env:landingZonePath/metadata.json" | ConvertFrom-Json -AsHashtable -Depth 4 + + $sourceSync = $lzConfig.repoSource -and $lzConfig.repoSource.source -and !$lzConfig.repoSource.disabled -and !$lzConfig.decommissioned + $templateRepoSync = !$sourceSync -and $lzConfig.repoTemplate -and !$lzConfig.disableWorkloadRepoTemplateSync + $deleteFilesEnabled = $sourceSync -or $templateRepoSync + $sourcePath = $sourceSync ? "$env:repoSourcesPath/$($lzConfig.repoSource.source)/contents" : $templateRepoSync ? "template-repo" : "" + $deleteFilesConfigPath = $sourceSync ? "$env:repoSourcesPath/$($lzConfig.repoSource.source)/delete-files.json" : $templateRepoSync ? "template-repo/delete-files.json" : "" + + #* Write outputs + $outputs = @{ + "repository" = "$($lzConfig.organization)/$($lzConfig.repoName)" + "source-sync" = $sourceSync + "source-path" = $sourcePath + "template-repo-sync" = $templateRepoSync + "template-repo" = $lzConfig.repoTemplate + "delete-files-enabled" = $sourceSync -or $templateRepoSync + "delete-files-config-path" = $deleteFilesConfigPath + } + + foreach ($output in $outputs.Keys) { + Write-Output "$output=$($outputs[$output])" >> $env:GITHUB_OUTPUT + } + + #* Checkout Landing Zone repository + - name: Checkout Landing Zone Repository + uses: actions/checkout@v4 + if: ${{ steps.lz-config.outputs.source-sync == 'true' || steps.lz-config.outputs.template-repo-sync == 'true' || steps.lz-config.outputs.delete-files-enabled == 'true' }} + with: + path: lz-repo + repository: ${{ steps.lz-config.outputs.repository }} + token: ${{ inputs.github-token }} + + #* Sync from source repository + - name: Checkout Template Repository + uses: actions/checkout@v4 + if: ${{ steps.lz-config.outputs.template-repo-sync == 'true' }} + with: + path: template-repo + repository: ${{ steps.lz-config.outputs.template-repo }} + token: ${{ inputs.github-token }} + + #* Copy files from source + - name: Copy files from source + if: ${{ steps.lz-config.outputs.source-sync == 'true' || steps.lz-config.outputs.template-repo-sync == 'true' }} + shell: pwsh + env: + sourcePath: ${{ steps.lz-config.outputs.source-path }} + run: | + #* Copy files from source + $exclusions = @(".git", "delete-files.json", "delete-files.jsonc") + Copy-Item -Path "$env:sourcePath/*" -Destination "lz-repo" -Recurse -Exclude $exclusions -Force + + #* Delete files + - name: Delete files + id: delete-files + if: ${{ steps.lz-config.outputs.delete-files-enabled == 'true' }} + shell: pwsh + env: + configurationPath: ${{ steps.lz-config.outputs.delete-files-config-path }} + actionPath: ${{ github.action_path }} + debug: ${{ runner.debug }} + run: | + #* Delete-Files.ps1 + $DebugPreference = [bool]$env:debug ? "Continue" : "SilentlyContinue" + + #* Test path + if (!(Test-Path -Path $env:configurationPath)) { + Write-Host "Skipping. No delete-files configuration file found." + exit + } + + #* Run script + $param = @{ + ConfigurationPath = $env:configurationPath + Path = "lz-repo" + Depth = 10 + } + $result = & "$($env:actionPath)/src/Delete-Files.ps1" @param + + #* Write outputs + Write-Output "deleted-files=$(, $result.DeletedFiles | ConvertTo-Json -Compress)" >> $env:GITHUB_OUTPUT + Write-Output "deleted-directories=$(, $result.DeletedDirectories | ConvertTo-Json -Compress)" >> $env:GITHUB_OUTPUT + + #* Push changes + - name: Push changes + if: ${{ steps.lz-config.outputs.source-sync == 'true' || steps.lz-config.outputs.template-repo-sync == 'true' || steps.lz-config.outputs.delete-files-enabled == 'true' }} + shell: pwsh + run: | + #* Push changes + git config --global user.name github-actions + git config --global user.email github-actions@github.com + + Push-Location "lz-repo" + git add . + $changes = [bool](git diff --cached --name-only) + if ($changes) { + git commit -m "[skip ci] Update Landing Zone Repository: Update files" + git push -q + } + else { + Write-Host "Skipping. No file changes detected." + } + Pop-Location diff --git a/src/Delete-Files.ps1 b/src/Delete-Files.ps1 new file mode 100644 index 0000000..0ddba9a --- /dev/null +++ b/src/Delete-Files.ps1 @@ -0,0 +1,115 @@ +[CmdletBinding()] +param ( + [Parameter(Mandatory = $false)] + [ValidateScript({ $_ | Test-Path -PathType Container })] + [string] + $Path = ".", + + [Parameter(Mandatory = $false)] + [ValidateScript({ $_ | Test-Path -PathType Leaf })] + [string] + $ConfigurationPath, + + [Parameter(Mandatory = $false)] + [int] + $Depth = 10 +) + +#* Import config file +$config = Get-Content $ConfigurationPath | ConvertFrom-Json -Depth 4 -AsHashtable -NoEnumerate + +#* Change location to Path +Push-Location -Path $Path + +#* Get directories +$rootDirectory = Get-Item -Path "." +$directories = Get-ChildItem -Recurse -Depth $Depth -Force -Directory +$directoriesToProcess = @( + $rootDirectory +) + +#* Process exclusions +foreach ($directory in $directories) { + $directoryFullPath = $directory.FullName + $directoryRelativePath = Resolve-Path -Relative -Path $directory.FullName + + #* Skip .git directory + if ($directoryRelativePath -eq ".git" -or $directoryRelativePath -like ".git/*" -or $directoryRelativePath -like "*/.git" -or $directoryRelativePath -like "*/.git/*") { + continue + } + + #* Skip excluded directories + $skip = $false + foreach ($entry in $config.directoriesToExclude) { + $entryFullPath = $ExecutionContext.SessionState.Path.GetUnresolvedProviderPathFromPSPath($entry) + + if ("$directoryFullPath/".StartsWith("$entryFullPath/")) { + $skip = $true + break + } + } + if ($skip) { + Write-Debug "[$directoryRelativePath] - File is in directory in the 'directoriesToExclude' property in the configuration file. Skipping." + continue + } + + $directoriesToProcess += $directory +} + +#* Initialize output object +$output = @{ + DeletedFiles = @() + DeletedDirectories = @() +} + +#* Process files +$index_filehashes = @{} +foreach ($directory in $directoriesToProcess) { + + $files = Get-ChildItem -Path $directory.FullName -File -Force + foreach ($file in $files) { + $fileRelativePath = Resolve-Path -Relative -Path $file.FullName + + foreach ($entry in $config.filesToDelete) { + if (Test-Path -Path $entry.path) { + $entryRelativePath = Resolve-Path -Relative -Path $entry.path + + if ($fileRelativePath -eq $entryRelativePath) { + if (!$index_filehashes.ContainsKey($fileRelativePath)) { + $index_filehashes.Add($fileRelativePath, (Get-FileHash -Path $file.FullName).Hash) + } + $fileHash = $index_filehashes[$fileRelativePath] + + if ($entry.hash -and $entry.hash -eq $fileHash -or $entry.hashes -contains $fileHash) { + Write-Host "[$fileRelativePath] - Matched hash. Deleting file. Hash [$fileHash]" + $file | Remove-Item -Force -Confirm:$false + $output.DeletedFiles += $fileRelativePath + } + else { + Write-Debug "[$fileRelativePath] - No matching hashes. Skipping" + } + } + } + } + } +} + +#* Delete empty directories +foreach ($directory in ($directoriesToProcess | Sort-Object -Descending { $_.FullName.Split("/").Count })) { + $directoryFullPath = $directory.FullName + $directoryRelativePath = Resolve-Path -Relative -Path $directory.FullName + + if ($directory.GetFiles().Count -eq 0 -and $directory.GetDirectories().Count -eq 0) { + Write-Host "[$directoryRelativePath] - Directory is empty. Deleting directory." + $directory | Remove-Item -Force -Confirm:$false + $output.DeletedDirectories += $directoryRelativePath + } + else { + Write-Debug "[$directoryRelativePath] - Directory is not empty. Skipping" + } +} + +Pop-Location + +#* Return +return $output \ No newline at end of file diff --git a/src/tests/Delete-Files.tests.ps1 b/src/tests/Delete-Files.tests.ps1 new file mode 100644 index 0000000..bef7885 --- /dev/null +++ b/src/tests/Delete-Files.tests.ps1 @@ -0,0 +1,90 @@ +Describe "Delete-Files.ps1" { + BeforeAll { + $script:mockDirectory = "$PSScriptRoot/mock" + } + + AfterAll { + Remove-Item -Path $script:mockDirectory -Recurse -Force -Confirm:$false -ErrorAction Ignore + } + + Context "When a file matches the path, but not the hash" { + BeforeAll { + New-Item -ItemType Directory -Path "$script:mockDirectory" -Force -Confirm:$false | Out-Null + "content1" | Out-File -FilePath "$script:mockDirectory/fileToDelete.txt" + "content2" | Out-File -FilePath "$script:mockDirectory/fileToDelete2.txt" + "content3" | Out-File -FilePath "$script:mockDirectory/fileDontDelete.txt" + New-Item -ItemType Directory -Path "$script:mockDirectory/dirToDelete" -Force -Confirm:$false | Out-Null + New-Item -ItemType Directory -Path "$script:mockDirectory/dirDontDelete" -Force -Confirm:$false | Out-Null + "subcontent2" | Out-File -FilePath "$script:mockDirectory/dirDontDelete/fileDontDelete.txt" + New-Item -ItemType Directory -Path "$script:mockDirectory/dirDontDeleteDepth2/subDirDepth3/subDirDepth4" -Force -Confirm:$false | Out-Null + "subcontent1" | Out-File -FilePath "$script:mockDirectory/dirDontDeleteDepth2/subDirDepth3/subDirDepth4/fileDontDeleteDepth5.txt" + + @{ + filesToDelete = @( + @{ + path = "$script:mockDirectory/fileDontDelete.txt" + hashes = @( + "3A888546831AE05A0EC1D040DE396262284E4B4FC0066A00D56016BF3955C90E" + ) + } + @{ + path = "$script:mockDirectory/fileToDelete.txt" + hashes = @( + "3A888546831AE05A0EC1D040DE396262284E4B4FC0066A00D56016BF3955C90E" + (Get-FileHash -Path "$script:mockDirectory/fileToDelete.txt").Hash + ) + } + @{ + path = "$script:mockDirectory/fileToDelete2.txt" + hash = (Get-FileHash -Path "$script:mockDirectory/fileToDelete2.txt").Hash + } + @{ + path = "$script:mockDirectory/dirDontDelete/fileDontDelete.txt" + hashes = @( + (Get-FileHash -Path "$script:mockDirectory/dirDontDelete/fileDontDelete.txt").Hash + ) + } + ) + directoriesToExclude = @( + "dirDontDelete" + ) + + } | ConvertTo-Json -Depth 4 | Out-File -FilePath config.json + + $script:res = ./src/Delete-Files.ps1 -Path $script:mockDirectory -ConfigurationPath config.json -Depth 5 + } + + AfterAll { + Remove-Item -Path config.json -Force -Confirm:$false + Remove-Item -Path $script:mockDirectory -Recurse -Force -Confirm:$false + } + + It "Should output the correct files and directories" { + $script:res.DeletedFiles | Should -HaveCount 2 + $script:res.DeletedFiles | Should -Contain "./fileToDelete.txt" + $script:res.DeletedDirectories | Should -HaveCount 1 + $script:res.DeletedDirectories | Should -Contain "./dirToDelete" + } + + It "Should delete the files with the correct hashes." { + Test-Path -Path "$script:mockDirectory/fileToDelete.txt" | Should -BeFalse + Test-Path -Path "$script:mockDirectory/fileToDelete2.txt" | Should -BeFalse + } + + It "Should not delete the file with the wrong hash." { + Test-Path -Path "$script:mockDirectory/fileDontDelete.txt" | Should -BeTrue + } + + It "Should not delete files further down than the 'Depth' parameter." { + Test-Path -Path "$script:mockDirectory/dirDontDeleteDepth2/subDirDepth3/subDirDepth4/fileDontDeleteDepth5.txt" | Should -BeTrue + } + + It "Should not delete files that matches any 'directoriesToExclude' entries." { + Test-Path -Path "$script:mockDirectory/dirDontDelete/fileDontDelete.txt" | Should -BeTrue + } + + It "Should delete empty directories" { + Test-Path -Path "$script:mockDirectory/dirToDelete" | Should -BeFalse + } + } +} \ No newline at end of file