├── .devcontainer ├── Dockerfile └── devcontainer.json ├── .github ├── dependabot.yml └── workflows │ ├── powershell.yml │ └── workflow.yml ├── .gitignore ├── GitVersion.yml ├── LICENSE ├── README.md ├── action.yml └── src └── deploymentfrequency.ps1 /.devcontainer/Dockerfile: -------------------------------------------------------------------------------- 1 | # See here for image contents: https://github.com/microsoft/vscode-dev-containers/tree/v0.245.2/containers/ubuntu/.devcontainer/base.Dockerfile 2 | 3 | # [Choice] Ubuntu version (use ubuntu-22.04 or ubuntu-18.04 on local arm64/Apple Silicon): ubuntu-22.04, ubuntu-20.04, ubuntu-18.04 4 | ARG VARIANT="jammy" 5 | FROM mcr.microsoft.com/vscode/devcontainers/base:0-${VARIANT} 6 | 7 | # [Optional] Uncomment this section to install additional OS packages. 8 | # RUN apt-get update && export DEBIAN_FRONTEND=noninteractive \ 9 | # && apt-get -y install --no-install-recommends 10 | 11 | 12 | -------------------------------------------------------------------------------- /.devcontainer/devcontainer.json: -------------------------------------------------------------------------------- 1 | // For format details, see https://aka.ms/devcontainer.json. For config options, see the README at: 2 | // https://github.com/microsoft/vscode-dev-containers/tree/v0.245.2/containers/ubuntu 3 | { 4 | "name": "Ubuntu", 5 | "build": { 6 | "dockerfile": "Dockerfile", 7 | // Update 'VARIANT' to pick an Ubuntu version: jammy / ubuntu-22.04, focal / ubuntu-20.04, bionic /ubuntu-18.04 8 | // Use ubuntu-22.04 or ubuntu-18.04 on local arm64/Apple Silicon. 9 | "args": { "VARIANT": "ubuntu-22.04" } 10 | }, 11 | 12 | // Use 'forwardPorts' to make a list of ports inside the container available locally. 13 | // "forwardPorts": [], 14 | 15 | // Use 'postCreateCommand' to run commands after the container is created. 16 | // "postCreateCommand": "src/deploymentfrequency.ps1 -ownerRepo 'SamSmithnz/SamsFeatureFlags' -workflows 'Feature Flags CI/CD,CodeQL' -branch 'Main' -numberOfDays 30 -patToken $env:PATTOKEN", 17 | 18 | 19 | // Comment out to connect as root instead. More info: https://aka.ms/vscode-remote/containers/non-root. 20 | "remoteUser": "vscode", 21 | "features": { 22 | "powershell": "latest" 23 | }, 24 | "customizations": { 25 | "vscode": { 26 | "extensions": [ 27 | "GitHub.copilot-labs" 28 | ] 29 | } 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | # Maintain dependencies for GitHub Actions 4 | - package-ecosystem: "github-actions" 5 | directory: "/" 6 | schedule: 7 | interval: "daily" 8 | time: "06:00" 9 | timezone: America/New_York 10 | assignees: 11 | - "samsmithnz" 12 | open-pull-requests-limit: 10 13 | groups: 14 | actions: 15 | patterns: ["*"] 16 | update-types: ["minor", "patch"] 17 | 18 | -------------------------------------------------------------------------------- /.github/workflows/powershell.yml: -------------------------------------------------------------------------------- 1 | # This workflow uses actions that are not certified by GitHub. 2 | # They are provided by a third-party and are governed by 3 | # separate terms of service, privacy policy, and support 4 | # documentation. 5 | # 6 | # https://github.com/microsoft/action-psscriptanalyzer 7 | # For more information on PSScriptAnalyzer in general, see 8 | # https://github.com/PowerShell/PSScriptAnalyzer 9 | 10 | name: PSScriptAnalyzer 11 | 12 | on: 13 | push: 14 | branches: [ "main" ] 15 | pull_request: 16 | branches: [ "main" ] 17 | 18 | permissions: 19 | contents: read 20 | 21 | jobs: 22 | build: 23 | permissions: 24 | contents: read # for actions/checkout to fetch code 25 | security-events: write # for github/codeql-action/upload-sarif to upload SARIF results 26 | actions: read # only required for a private repository by github/codeql-action/upload-sarif to get the Action run status 27 | name: PSScriptAnalyzer 28 | runs-on: ubuntu-latest 29 | steps: 30 | - uses: actions/checkout@v4 31 | 32 | - name: Run PSScriptAnalyzer 33 | uses: microsoft/psscriptanalyzer-action@6b2948b1944407914a58661c49941824d149734f 34 | with: 35 | # Check https://github.com/microsoft/action-psscriptanalyzer for more info about the options. 36 | # The below set up runs PSScriptAnalyzer to your entire repository and runs some basic security rules. 37 | path: .\ 38 | recurse: true 39 | # Include your own basic security rules. Removing this option will run all the rules 40 | includeRule: '"PSAvoidGlobalAliases", "PSAvoidUsingConvertToSecureStringWithPlainText"' 41 | output: results.sarif 42 | 43 | # Upload the SARIF file generated in the previous step 44 | - name: Upload SARIF results file 45 | uses: github/codeql-action/upload-sarif@v3 46 | with: 47 | sarif_file: results.sarif 48 | -------------------------------------------------------------------------------- /.github/workflows/workflow.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: [ "main" ] 6 | pull_request: 7 | branches: [ "main" ] 8 | workflow_dispatch: 9 | 10 | jobs: 11 | build: 12 | runs-on: ubuntu-latest 13 | outputs: # https://stackoverflow.com/questions/59175332/using-output-from-a-previous-job-in-a-new-one-in-a-github-action 14 | Version: ${{ steps.gitversion.outputs.SemVer }} 15 | CommitsSinceVersionSource: ${{ steps.gitversion.outputs.CommitsSinceVersionSource }} 16 | steps: 17 | - uses: actions/checkout@v4 18 | with: 19 | fetch-depth: 0 #fetch-depth is needed for GitVersion 20 | #Install and calculate the new version with GitVersion 21 | - name: Install GitVersion 22 | uses: gittools/actions/gitversion/setup@v3.2.1 23 | with: 24 | versionSpec: 5.x 25 | - name: Determine Version 26 | uses: gittools/actions/gitversion/execute@v3.2.1 27 | id: gitversion # step id used as reference for output values 28 | - name: Display GitVersion outputs 29 | run: | 30 | echo "Version: ${{ steps.gitversion.outputs.SemVer }}" 31 | echo "CommitsSinceVersionSource: ${{ steps.gitversion.outputs.CommitsSinceVersionSource }}" 32 | 33 | # checkout the code from this branch, and then test the action from here 34 | test: 35 | runs-on: ubuntu-latest 36 | steps: 37 | - name: checkout the code from this branch 38 | uses: actions/checkout@v4 39 | with: 40 | ref: ${{ github.ref }} 41 | 42 | #basic tests 43 | - name: Test this repo 44 | uses: ./ # the ./ runs the action.yml in this repo 45 | with: 46 | workflows: 'CI' 47 | - name: Test another repo with all arguments 48 | uses: ./ 49 | with: 50 | workflows: 'CI/CD' 51 | owner-repo: 'DeveloperMetrics/DevOpsMetrics' 52 | default-branch: 'main' 53 | number-of-days: 30 54 | - name: Test no workflow 55 | uses: ./ 56 | with: 57 | workflows: '' 58 | owner-repo: 'samsmithnz/GitHubToAzureDevOps' 59 | 60 | #authenication tests 61 | - name: Test elite repo with PAT Token 62 | uses: ./ 63 | with: 64 | workflows: 'Feature Flags CI/CD' 65 | owner-repo: 'samsmithnz/SamsFeatureFlags' 66 | pat-token: "${{ secrets.PATTOKEN }}" 67 | - name: Test elite repo, multiple workflows, with PAT Token 68 | uses: ./ 69 | with: 70 | workflows: 'Feature Flags CI/CD,CodeQL' 71 | owner-repo: 'samsmithnz/SamsFeatureFlags' 72 | pat-token: "${{ secrets.PATTOKEN }}" 73 | - name: Test elite repo with PAT Token with invalid TOKEN 74 | uses: ./ 75 | with: 76 | workflows: 'Feature Flags CI/CD' 77 | owner-repo: 'samsmithnz/SamsFeatureFlags' 78 | pat-token: "INVALIDPATTOKEN" 79 | - name: Test unknown repo 80 | uses: DeveloperMetrics/deployment-frequency@main 81 | with: 82 | workflows: 'CI/CD' 83 | owner-repo: 'samsmithnz/SamsFeatureFlags2' 84 | - name: Test this repo with GitHub Actions Token 85 | uses: ./ 86 | with: 87 | workflows: 'CI' 88 | actions-token: "${{ secrets.GITHUB_TOKEN }}" 89 | - name: Test this repo with GitHub App Token 90 | uses: ./ 91 | with: 92 | workflows: 'CI' 93 | app-id: "${{ secrets.APPID }}" 94 | app-install-id: "${{ secrets.APPINSTALLID }}" 95 | app-private-key: "${{ secrets.APPPRIVATEKEY }}" 96 | - name: Test elite repo with GitHub App Token 97 | uses: ./ 98 | with: 99 | workflows: 'Feature Flags CI/CD' 100 | owner-repo: 'samsmithnz/SamsFeatureFlags' 101 | app-id: "${{ secrets.APPID }}" 102 | app-install-id: "${{ secrets.APPINSTALLID }}" 103 | app-private-key: "${{ secrets.APPPRIVATEKEY }}" 104 | 105 | releaseAction: 106 | runs-on: ubuntu-latest 107 | needs: 108 | - build 109 | - test 110 | if: github.ref == 'refs/heads/main' 111 | steps: 112 | - name: Display GitVersion outputs 113 | run: | 114 | echo "Version: ${{ needs.build.outputs.Version }}" 115 | echo "CommitsSinceVersionSource: ${{ needs.build.outputs.CommitsSinceVersionSource }}" 116 | - name: Create Release 117 | id: create_release 118 | uses: actions/create-release@v1 119 | if: needs.build.outputs.CommitsSinceVersionSource > 0 #Only create a release if there has been a commit/version change 120 | env: 121 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} # This token is provided by Actions, you do not need to create your own token 122 | with: 123 | tag_name: "v${{ needs.build.outputs.Version }}" 124 | release_name: "v${{ needs.build.outputs.Version }}" 125 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | ## Ignore Visual Studio temporary files, build results, and 2 | ## files generated by popular Visual Studio add-ons. 3 | ## 4 | ## Get latest from https://github.com/github/gitignore/blob/master/VisualStudio.gitignore 5 | 6 | # User-specific files 7 | *.rsuser 8 | *.suo 9 | *.user 10 | *.userosscache 11 | *.sln.docstates 12 | 13 | # User-specific files (MonoDevelop/Xamarin Studio) 14 | *.userprefs 15 | 16 | # Mono auto generated files 17 | mono_crash.* 18 | 19 | # Build results 20 | [Dd]ebug/ 21 | [Dd]ebugPublic/ 22 | [Rr]elease/ 23 | [Rr]eleases/ 24 | x64/ 25 | x86/ 26 | [Aa][Rr][Mm]/ 27 | [Aa][Rr][Mm]64/ 28 | bld/ 29 | [Bb]in/ 30 | [Oo]bj/ 31 | [Ll]og/ 32 | [Ll]ogs/ 33 | 34 | # Visual Studio 2015/2017 cache/options directory 35 | .vs/ 36 | # Uncomment if you have tasks that create the project's static files in wwwroot 37 | #wwwroot/ 38 | 39 | # Visual Studio 2017 auto generated files 40 | Generated\ Files/ 41 | 42 | # MSTest test Results 43 | [Tt]est[Rr]esult*/ 44 | [Bb]uild[Ll]og.* 45 | 46 | # NUnit 47 | *.VisualState.xml 48 | TestResult.xml 49 | nunit-*.xml 50 | 51 | # Build Results of an ATL Project 52 | [Dd]ebugPS/ 53 | [Rr]eleasePS/ 54 | dlldata.c 55 | 56 | # Benchmark Results 57 | BenchmarkDotNet.Artifacts/ 58 | 59 | # .NET Core 60 | project.lock.json 61 | project.fragment.lock.json 62 | artifacts/ 63 | 64 | # StyleCop 65 | StyleCopReport.xml 66 | 67 | # Files built by Visual Studio 68 | *_i.c 69 | *_p.c 70 | *_h.h 71 | *.ilk 72 | *.meta 73 | *.obj 74 | *.iobj 75 | *.pch 76 | *.pdb 77 | *.ipdb 78 | *.pgc 79 | *.pgd 80 | *.rsp 81 | *.sbr 82 | *.tlb 83 | *.tli 84 | *.tlh 85 | *.tmp 86 | *.tmp_proj 87 | *_wpftmp.csproj 88 | *.log 89 | *.vspscc 90 | *.vssscc 91 | .builds 92 | *.pidb 93 | *.svclog 94 | *.scc 95 | 96 | # Chutzpah Test files 97 | _Chutzpah* 98 | 99 | # Visual C++ cache files 100 | ipch/ 101 | *.aps 102 | *.ncb 103 | *.opendb 104 | *.opensdf 105 | *.sdf 106 | *.cachefile 107 | *.VC.db 108 | *.VC.VC.opendb 109 | 110 | # Visual Studio profiler 111 | *.psess 112 | *.vsp 113 | *.vspx 114 | *.sap 115 | 116 | # Visual Studio Trace Files 117 | *.e2e 118 | 119 | # TFS 2012 Local Workspace 120 | $tf/ 121 | 122 | # Guidance Automation Toolkit 123 | *.gpState 124 | 125 | # ReSharper is a .NET coding add-in 126 | _ReSharper*/ 127 | *.[Rr]e[Ss]harper 128 | *.DotSettings.user 129 | 130 | # TeamCity is a build add-in 131 | _TeamCity* 132 | 133 | # DotCover is a Code Coverage Tool 134 | *.dotCover 135 | 136 | # AxoCover is a Code Coverage Tool 137 | .axoCover/* 138 | !.axoCover/settings.json 139 | 140 | # Visual Studio code coverage results 141 | *.coverage 142 | *.coveragexml 143 | 144 | # NCrunch 145 | _NCrunch_* 146 | .*crunch*.local.xml 147 | nCrunchTemp_* 148 | 149 | # MightyMoose 150 | *.mm.* 151 | AutoTest.Net/ 152 | 153 | # Web workbench (sass) 154 | .sass-cache/ 155 | 156 | # Installshield output folder 157 | [Ee]xpress/ 158 | 159 | # DocProject is a documentation generator add-in 160 | DocProject/buildhelp/ 161 | DocProject/Help/*.HxT 162 | DocProject/Help/*.HxC 163 | DocProject/Help/*.hhc 164 | DocProject/Help/*.hhk 165 | DocProject/Help/*.hhp 166 | DocProject/Help/Html2 167 | DocProject/Help/html 168 | 169 | # Click-Once directory 170 | publish/ 171 | 172 | # Publish Web Output 173 | *.[Pp]ublish.xml 174 | *.azurePubxml 175 | # Note: Comment the next line if you want to checkin your web deploy settings, 176 | # but database connection strings (with potential passwords) will be unencrypted 177 | *.pubxml 178 | *.publishproj 179 | 180 | # Microsoft Azure Web App publish settings. Comment the next line if you want to 181 | # checkin your Azure Web App publish settings, but sensitive information contained 182 | # in these scripts will be unencrypted 183 | PublishScripts/ 184 | 185 | # NuGet Packages 186 | *.nupkg 187 | # NuGet Symbol Packages 188 | *.snupkg 189 | # The packages folder can be ignored because of Package Restore 190 | **/[Pp]ackages/* 191 | # except build/, which is used as an MSBuild target. 192 | !**/[Pp]ackages/build/ 193 | # Uncomment if necessary however generally it will be regenerated when needed 194 | #!**/[Pp]ackages/repositories.config 195 | # NuGet v3's project.json files produces more ignorable files 196 | *.nuget.props 197 | *.nuget.targets 198 | 199 | # Microsoft Azure Build Output 200 | csx/ 201 | *.build.csdef 202 | 203 | # Microsoft Azure Emulator 204 | ecf/ 205 | rcf/ 206 | 207 | # Windows Store app package directories and files 208 | AppPackages/ 209 | BundleArtifacts/ 210 | Package.StoreAssociation.xml 211 | _pkginfo.txt 212 | *.appx 213 | *.appxbundle 214 | *.appxupload 215 | 216 | # Visual Studio cache files 217 | # files ending in .cache can be ignored 218 | *.[Cc]ache 219 | # but keep track of directories ending in .cache 220 | !?*.[Cc]ache/ 221 | 222 | # Others 223 | ClientBin/ 224 | ~$* 225 | *~ 226 | *.dbmdl 227 | *.dbproj.schemaview 228 | *.jfm 229 | *.pfx 230 | *.publishsettings 231 | orleans.codegen.cs 232 | 233 | # Including strong name files can present a security risk 234 | # (https://github.com/github/gitignore/pull/2483#issue-259490424) 235 | #*.snk 236 | 237 | # Since there are multiple workflows, uncomment next line to ignore bower_components 238 | # (https://github.com/github/gitignore/pull/1529#issuecomment-104372622) 239 | #bower_components/ 240 | 241 | # RIA/Silverlight projects 242 | Generated_Code/ 243 | 244 | # Backup & report files from converting an old project file 245 | # to a newer Visual Studio version. Backup files are not needed, 246 | # because we have git ;-) 247 | _UpgradeReport_Files/ 248 | Backup*/ 249 | UpgradeLog*.XML 250 | UpgradeLog*.htm 251 | ServiceFabricBackup/ 252 | *.rptproj.bak 253 | 254 | # SQL Server files 255 | *.mdf 256 | *.ldf 257 | *.ndf 258 | 259 | # Business Intelligence projects 260 | *.rdl.data 261 | *.bim.layout 262 | *.bim_*.settings 263 | *.rptproj.rsuser 264 | *- [Bb]ackup.rdl 265 | *- [Bb]ackup ([0-9]).rdl 266 | *- [Bb]ackup ([0-9][0-9]).rdl 267 | 268 | # Microsoft Fakes 269 | FakesAssemblies/ 270 | 271 | # GhostDoc plugin setting file 272 | *.GhostDoc.xml 273 | 274 | # Node.js Tools for Visual Studio 275 | .ntvs_analysis.dat 276 | node_modules/ 277 | 278 | # Visual Studio 6 build log 279 | *.plg 280 | 281 | # Visual Studio 6 workspace options file 282 | *.opt 283 | 284 | # Visual Studio 6 auto-generated workspace file (contains which files were open etc.) 285 | *.vbw 286 | 287 | # Visual Studio LightSwitch build output 288 | **/*.HTMLClient/GeneratedArtifacts 289 | **/*.DesktopClient/GeneratedArtifacts 290 | **/*.DesktopClient/ModelManifest.xml 291 | **/*.Server/GeneratedArtifacts 292 | **/*.Server/ModelManifest.xml 293 | _Pvt_Extensions 294 | 295 | # Paket dependency manager 296 | .paket/paket.exe 297 | paket-files/ 298 | 299 | # FAKE - F# Make 300 | .fake/ 301 | 302 | # CodeRush personal settings 303 | .cr/personal 304 | 305 | # Python Tools for Visual Studio (PTVS) 306 | __pycache__/ 307 | *.pyc 308 | 309 | # Cake - Uncomment if you are using it 310 | # tools/** 311 | # !tools/packages.config 312 | 313 | # Tabs Studio 314 | *.tss 315 | 316 | # Telerik's JustMock configuration file 317 | *.jmconfig 318 | 319 | # BizTalk build output 320 | *.btp.cs 321 | *.btm.cs 322 | *.odx.cs 323 | *.xsd.cs 324 | 325 | # OpenCover UI analysis results 326 | OpenCover/ 327 | 328 | # Azure Stream Analytics local run output 329 | ASALocalRun/ 330 | 331 | # MSBuild Binary and Structured Log 332 | *.binlog 333 | 334 | # NVidia Nsight GPU debugger configuration file 335 | *.nvuser 336 | 337 | # MFractors (Xamarin productivity tool) working folder 338 | .mfractor/ 339 | 340 | # Local History for Visual Studio 341 | .localhistory/ 342 | 343 | # BeatPulse healthcheck temp database 344 | healthchecksdb 345 | 346 | # Backup folder for Package Reference Convert tool in Visual Studio 2017 347 | MigrationBackup/ 348 | 349 | # Ionide (cross platform F# VS Code tools) working folder 350 | .ionide/ 351 | -------------------------------------------------------------------------------- /GitVersion.yml: -------------------------------------------------------------------------------- 1 | next-version: 1.5.0 2 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Sam Smith 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Deployment Frequency 2 | A GitHub Action to roughly calculate DORA deployment frequency. This is not meant to be an exhaustive calculation, but we are able to approximate fairly close for most of workflows. Why? Our [insights](https://samlearnsazure.blog/2022/08/23/my-insights-about-measuring-dora-devops-metrics-and-how-you-can-learn-from-my-mistakes/) indicated that many applications don't need exhaustive DORA analysis - a high level, order of magnitude result is accurate for most workloads. 3 | 4 | [![CI](https://github.com/DeveloperMetrics/deployment-frequency/actions/workflows/workflow.yml/badge.svg)](https://github.com/DeveloperMetrics/deployment-frequency/actions/workflows/workflow.yml) 5 | [![Current Release](https://img.shields.io/github/release/DeveloperMetrics/deployment-frequency/all.svg)](https://github.com/DeveloperMetrics/deployment-frequency/releases) 6 | 7 | ## Current Calculation 8 | - Get the last 100 completed workflows 9 | - For each workflow, if it started in the last 30 days, and add it to a secondary filtered list - this is the number of deployments in the last 30 days 10 | - With this filtered list, divide the count by the 30 days for a number of deployments per day 11 | - Then translate this result to friendly n days/weeks/months. 12 | - As the cost is relatively low (1 Rest API call to GitHub), a result is typically returned in 5-10s. 13 | 14 | ## Current Limitations 15 | - Only looks at the last 100 completed workflows. If number of deployments to the target branch is low, this will skew the result. 16 | 17 | ## Inputs 18 | - `workflows`: required, string, The name of the workflows to process. Multiple workflows can be separated by `,` 19 | - `owner-repo`: optional, string, defaults to the repo where the action runs. Can target another owner or org and repo. e.g. `'DeveloperMetrics/DevOpsMetrics'`, but will require authenication (see below) 20 | - `default-branch`: optional, string, defaults to `main` 21 | - `number-of-days`: optional, integer, defaults to `30` (days) 22 | - `pat-token`: optional, string, defaults to ''. Can be set with GitHub PAT token. Ensure that `Read access to actions and metadata` permission is set. This is a secret, never directly add this into the actions workflow, use a secret. 23 | - `actions-token`: optional, string, defaults to ''. Can be set with `${{ secrets.GITHUB_TOKEN }}` in the action 24 | - `app-id`: optional, string, defaults to '', application id of the registered GitHub app 25 | - `app-install-id`: optional, string, defaults to '', id of the installed instance of the GitHub app 26 | - `app-private-key`: optional, string, defaults to '', private key which has been generated for the installed instance of the GitHub app. Must be provided without leading `'-----BEGIN RSA PRIVATE KEY----- '` and trailing `' -----END RSA PRIVATE KEY-----'`. 27 | 28 | To test the current repo (same as where the action runs) 29 | ```yml 30 | - uses: DeveloperMetrics/deployment-frequency@main 31 | with: 32 | workflows: 'CI' 33 | ``` 34 | 35 | To test another repo, with all arguments 36 | ```yml 37 | - name: Test another repo 38 | uses: DeveloperMetrics/deployment-frequency@main 39 | with: 40 | workflows: 'CI/CD' 41 | owner-repo: 'DeveloperMetrics/DevOpsMetrics' 42 | default-branch: 'main' 43 | number-of-days: 30 44 | ``` 45 | 46 | To use a PAT token to access another (potentially private) repo: 47 | ```yml 48 | - name: Test elite repo with PAT Token 49 | uses: DeveloperMetrics/deployment-frequency@main 50 | with: 51 | workflows: 'CI/CD' 52 | owner-repo: 'samsmithnz/SamsFeatureFlags' 53 | pat-token: "${{ secrets.PATTOKEN }}" 54 | ``` 55 | 56 | Use the built in Actions GitHub Token to retrieve the metrics 57 | ```yml 58 | - name: Test this repo with GitHub Token 59 | uses: DeveloperMetrics/deployment-frequency@main 60 | with: 61 | workflows: 'CI' 62 | actions-token: "${{ secrets.GITHUB_TOKEN }}" 63 | ``` 64 | 65 | Gather the metric from another repository using GitHub App authentication method: 66 | ```yml 67 | - name: Test another repo with GitHub App 68 | uses: DeveloperMetrics/deployment-frequency@main 69 | with: 70 | workflows: 'CI' 71 | owner-repo: 'DeveloperMetrics/some-other-repo' 72 | app-id: "${{ secrets.APPID }}" 73 | app-install-id: "${{ secrets.APPINSTALLID }}" 74 | app-private-key: "${{ secrets.APPPRIVATEKEY }}" 75 | ``` 76 | 77 | Use the markdown file output for some other action downstream: 78 | ```yml 79 | - name: Generate deployment frequency markdown file 80 | uses: DeveloperMetrics/deployment-frequency@main 81 | id: deployment-frequency 82 | with: 83 | workflows: 'CI' 84 | actions-token: "${{ secrets.GITHUB_TOKEN }}" 85 | - run: cat ${{ steps.deployment-frequency.outputs.markdown-file }}) 86 | ``` 87 | 88 | # Output 89 | 90 | Current output to the log shows the inputs, authenication method, rate limit consumption, and then the actual deployment frequency 91 | ``` 92 | Owner/Repo: samsmithnz/SamsFeatureFlags 93 | Workflows: Feature Flags CI/CD 94 | Branch: main 95 | Number of days: 30 96 | Authentication detected: GITHUB APP TOKEN 97 | Rate limit consumption: 10 / 5000 98 | Deployment frequency over last 30 days, is 1.2 per day, with a DORA rating of 'Elite' 99 | ``` 100 | 101 | In the job summary, we show a badge with details: 102 | 103 | --- 104 | ![Deployment Frequency](https://img.shields.io/badge/frequency-4.67%20times%20per%20week-green?logo=github&label=Deployment%20frequency)
105 | **Definition:** For the primary application or service, how often is it successfully deployed to production.
106 | **Results:** Deployment frequency is **4.67 times per week** with a **High** rating, over the last **30 days**.
107 | **Details**:
108 | - Repository: DeveloperMetrics/deployment-frequency using main branch 109 | - Workflow(s) used: CI 110 | - Active days of deployment: 13 days 111 | --- 112 | -------------------------------------------------------------------------------- /action.yml: -------------------------------------------------------------------------------- 1 | name: 'DORA deployment frequency' 2 | description: 'A GitHub Action to roughly calculate DORA deployment frequency' 3 | author: developermetrics.org 4 | branding: 5 | icon: activity 6 | color: gray-dark 7 | inputs: 8 | workflows: # required. string array of the name of the workflows to process 9 | description: the workflow name that is being scanned. Separate multiple workflows with commas 10 | required: true 11 | owner-repo: # optional, defaults to current owner/repo 12 | description: target org/repo or owner/repo to run the calculation on 13 | required: true 14 | default: "${{ github.repository }}" 15 | default-branch: # optional, defaults to main 16 | description: branch that is deploying to production 17 | required: true 18 | default: 'main' 19 | number-of-days: # optional, defaults to 30 20 | description: number of days to scan 21 | required: true 22 | default: "30" 23 | pat-token: # optional, defaults to an empty string ("") 24 | description: GitHub PAT Token 25 | default: "" 26 | actions-token: # optional, defaults to an empty string ("") 27 | description: GitHub Actions Token, commonly passed in as a variable (secrets.GITHUB_TOKEN) 28 | default: "" 29 | app-id: 30 | description: 'application id of the registered GitHub app' 31 | default: "" 32 | app-install-id: 33 | description: 'id of the installed instance of the GitHub app' 34 | default: "" 35 | app-private-key: 36 | description: 'private key which has been generated for the installed instance of the GitHub app' 37 | default: "" 38 | outputs: 39 | markdown-file: 40 | description: "The markdown that will be posted to the job summary, so that the markdown can saved and used other places (e.g.: README.md)" 41 | value: ${{ steps.deployment-frequency.outputs.markdown-file }} 42 | runs: 43 | using: "composite" 44 | steps: 45 | - name: Run DORA deployment frequency 46 | id: deployment-frequency 47 | shell: pwsh 48 | run: | 49 | $result = ${{ github.action_path }}/src/deploymentfrequency.ps1 -ownerRepo "${{ inputs.owner-repo }}" -workflows "${{ inputs.workflows }}" -branch "${{ inputs.default-branch }}" -numberOfDays ${{ inputs.number-of-days }} -patToken "${{ inputs.pat-token }}" -actionsToken "${{ inputs.actions-token }}" -appId "${{ inputs.app-id }}" -appInstallationId "${{ inputs.app-install-id }}" -appPrivateKey "${{ inputs.app-private-key }}" 50 | $filePath="dora-deployment-frequency-markdown.md" 51 | Set-Content -Path $filePath -Value $result 52 | "markdown-file=$filePath" | Out-File -FilePath $env:GITHUB_OUTPUT -Append 53 | Write-Output $result >> $env:GITHUB_STEP_SUMMARY 54 | -------------------------------------------------------------------------------- /src/deploymentfrequency.ps1: -------------------------------------------------------------------------------- 1 | #Parameters for the top level deploymentfrequency.ps1 PowerShell script 2 | Param( 3 | [string] $ownerRepo, 4 | [string] $workflows, 5 | [string] $branch, 6 | [Int32] $numberOfDays, 7 | [string] $patToken = "", 8 | [string] $actionsToken = "", 9 | [string] $appId = "", 10 | [string] $appInstallationId = "", 11 | [string] $appPrivateKey = "" 12 | ) 13 | 14 | #The main function 15 | function Main ([string] $ownerRepo, 16 | [string] $workflows, 17 | [string] $branch, 18 | [Int32] $numberOfDays, 19 | [string] $patToken = "", 20 | [string] $actionsToken = "", 21 | [string] $appId = "", 22 | [string] $appInstallationId = "", 23 | [string] $appPrivateKey = "") 24 | { 25 | 26 | #========================================== 27 | #Input processing 28 | $ownerRepoArray = $ownerRepo -split '/' 29 | $owner = $ownerRepoArray[0] 30 | $repo = $ownerRepoArray[1] 31 | $workflowsArray = $workflows -split ',' 32 | $numberOfDays = $numberOfDays 33 | Write-Host "Owner/Repo: $owner/$repo" 34 | Write-Host "Workflows: $workflows" 35 | Write-Host "Branch: $branch" 36 | Write-Host "Number of days: $numberOfDays" 37 | 38 | #========================================== 39 | # Get authorization headers 40 | $authHeader = GetAuthHeader $patToken $actionsToken $appId $appInstallationId $appPrivateKey 41 | 42 | #========================================== 43 | #Get workflow definitions from github 44 | $uri = "https://api.github.com/repos/$owner/$repo/actions/workflows" 45 | if (!$authHeader) 46 | { 47 | #No authentication 48 | $workflowsResponse = Invoke-RestMethod -Uri $uri -ContentType application/json -Method Get -SkipHttpErrorCheck -StatusCodeVariable "HTTPStatus" 49 | } 50 | else 51 | { 52 | #there is authentication 53 | $workflowsResponse = Invoke-RestMethod -Uri $uri -ContentType application/json -Method Get -Headers @{Authorization=($authHeader["Authorization"])} -SkipHttpErrorCheck -StatusCodeVariable "HTTPStatus" 54 | #$workflowsResponse = Invoke-RestMethod -Uri $uri -ContentType application/json -Method Get -Headers @{Authorization=("Basic {0}" -f $base64AuthInfo)} -ErrorAction Stop 55 | } 56 | if ($HTTPStatus -eq "404") 57 | { 58 | Write-Output "Repo is not found or you do not have access" 59 | break 60 | } 61 | 62 | #Extract workflow ids from the definitions, using the array of names. Number of Ids should == number of workflow names 63 | $workflowIds = [System.Collections.ArrayList]@() 64 | $workflowNames = [System.Collections.ArrayList]@() 65 | Foreach ($workflow in $workflowsResponse.workflows){ 66 | 67 | Foreach ($arrayItem in $workflowsArray){ 68 | if ($workflow.name -eq $arrayItem) 69 | { 70 | #This looks odd: but assigning to a (throwaway) variable stops the index of the arraylist being output to the console. Using an arraylist over an array has advantages making this worth it for here 71 | if (!$workflowIds.Contains($workflow.id)) 72 | { 73 | $result = $workflowIds.Add($workflow.id) 74 | } 75 | if (!$workflowNames.Contains($workflow.name)) 76 | { 77 | $result = $workflowNames.Add($workflow.name) 78 | } 79 | } 80 | } 81 | } 82 | 83 | #========================================== 84 | #Filter to workflows that were successful. Measure the number by date/day. Aggegate workflows together 85 | $dateList = @() 86 | $uniqueDates = @() 87 | $deploymentsPerDayList = @() 88 | 89 | #For each workflow id, get the last 100 workflows from github 90 | Foreach ($workflowId in $workflowIds){ 91 | #Get workflow definitions from github 92 | $uri2 = "https://api.github.com/repos/$owner/$repo/actions/workflows/$workflowId/runs?per_page=100&status=success" 93 | if (!$authHeader) 94 | { 95 | $workflowRunsResponse = Invoke-RestMethod -Uri $uri2 -ContentType application/json -Method Get -SkipHttpErrorCheck -StatusCodeVariable "HTTPStatus" 96 | } 97 | else 98 | { 99 | $workflowRunsResponse = Invoke-RestMethod -Uri $uri2 -ContentType application/json -Method Get -Headers @{Authorization=($authHeader["Authorization"])} -SkipHttpErrorCheck -StatusCodeVariable "HTTPStatus" 100 | } 101 | 102 | $buildTotal = 0 103 | Foreach ($run in $workflowRunsResponse.workflow_runs){ 104 | #Count workflows that are successfully completed, on the target branch, and were created within the day range we are looking at 105 | if ($run.head_branch -eq $branch -and $run.created_at -gt (Get-Date).AddDays(-$numberOfDays)) 106 | { 107 | #Write-Host "Adding item with status $($run.status), branch $($run.head_branch), created at $($run.created_at), compared to $((Get-Date).AddDays(-$numberOfDays))" 108 | $buildTotal++ 109 | #get the workflow start and end time 110 | $dateList += New-Object PSObject -Property @{start_datetime=$run.created_at;end_datetime=$run.updated_at} 111 | $uniqueDates += $run.created_at.Date.ToString("yyyy-MM-dd") 112 | } 113 | } 114 | 115 | if ($dateList.Length -gt 0) 116 | { 117 | #========================================== 118 | #Calculate deployments per day 119 | $deploymentsPerDay = 0 120 | 121 | if ($dateList.Count -gt 0 -and $numberOfDays -gt 0) 122 | { 123 | $deploymentsPerDay = $dateList.Count / $numberOfDays 124 | } 125 | $deploymentsPerDayList += $deploymentsPerDay 126 | #Write-Host "Adding to list, workflow id $workflowId deployments per day of $deploymentsPerDay" 127 | } 128 | } 129 | 130 | $totalDeployments = 0 131 | Foreach ($deploymentItem in $deploymentsPerDayList){ 132 | $totalDeployments += $deploymentItem 133 | } 134 | if ($deploymentsPerDayList.Length -gt 0) 135 | { 136 | $deploymentsPerDay = $totalDeployments / $deploymentsPerDayList.Length 137 | } 138 | #Write-Host "Total deployments $totalDeployments with a final deployments value of $deploymentsPerDay" 139 | 140 | #========================================== 141 | #Show current rate limit 142 | $uri3 = "https://api.github.com/rate_limit" 143 | if (!$authHeader) 144 | { 145 | $rateLimitResponse = Invoke-RestMethod -Uri $uri3 -ContentType application/json -Method Get -SkipHttpErrorCheck -StatusCodeVariable "HTTPStatus" 146 | } 147 | else 148 | { 149 | $rateLimitResponse = Invoke-RestMethod -Uri $uri3 -ContentType application/json -Method Get -Headers @{Authorization=($authHeader["Authorization"])} -SkipHttpErrorCheck -StatusCodeVariable "HTTPStatus" 150 | } 151 | Write-Host "Rate limit consumption: $($rateLimitResponse.rate.used) / $($rateLimitResponse.rate.limit)" 152 | 153 | #========================================== 154 | #Calculate deployments per day 155 | $deploymentsPerDay = 0 156 | 157 | if ($dateList.Count -gt 0 -and $numberOfDays -gt 0) 158 | { 159 | $deploymentsPerDay = $dateList.Count / $numberOfDays 160 | # get unique values in $uniqueDates 161 | $uniqueDates = $uniqueDates | Sort-Object | Get-Unique 162 | } 163 | 164 | #========================================== 165 | #output result 166 | $dailyDeployment = 1 167 | $weeklyDeployment = 1 / 7 168 | $monthlyDeployment = 1 / 30 169 | $everySixMonthsDeployment = 1 / (6 * 30) #Every 6 months 170 | $yearlyDeployment = 1 / 365 171 | 172 | #Calculate rating, metric, and unit 173 | if ($deploymentsPerDay -le 0) 174 | { 175 | $rating = "None" 176 | $color = "lightgrey" 177 | $displayMetric = 0 178 | $displayUnit = "per day" 179 | } 180 | elseif ($deploymentsPerDay -gt $dailyDeployment) 181 | { 182 | $rating = "Elite" 183 | $color = "brightgreen" 184 | $displayMetric = [math]::Round($deploymentsPerDay,2) 185 | $displayUnit = "per day" 186 | } 187 | elseif ($deploymentsPerDay -le $dailyDeployment -and $deploymentsPerDay -ge $weeklyDeployment) 188 | { 189 | $rating = "High" 190 | $color = "green" 191 | $displayMetric = [math]::Round($deploymentsPerDay * 7,2) 192 | $displayUnit = "times per week" 193 | } 194 | elseif ($deploymentsPerDay -lt $weeklyDeployment -and $deploymentsPerDay -ge $monthlyDeployment) 195 | { 196 | $rating = "Medium" 197 | $color = "yellow" 198 | $displayMetric = [math]::Round($deploymentsPerDay * 30,2) 199 | $displayUnit = "times per month" 200 | } 201 | elseif ($deploymentsPerDay -lt $monthlyDeployment -and $deploymentsPerDay -gt $yearlyDeployment) 202 | { 203 | $rating = "Low" 204 | $color = "red" 205 | $displayMetric = [math]::Round($deploymentsPerDay * 30,2) 206 | $displayUnit = "times per month" 207 | } 208 | elseif ($deploymentsPerDay -le $yearlyDeployment) 209 | { 210 | $rating = "Low" 211 | $color = "red" 212 | $displayMetric = [math]::Round($deploymentsPerDay * 365,2) 213 | $displayUnit = "times per year" 214 | } 215 | 216 | if ($dateList.Count -gt 0 -and $numberOfDays -gt 0) 217 | { 218 | Write-Host "Deployment frequency over last $numberOfDays days, is $displayMetric $displayUnit, with a DORA rating of '$rating'" 219 | return GetFormattedMarkdown -workflowNames $workflowNames -displayMetric $displayMetric -displayUnit $displayUnit -repo $ownerRepo -branch $branch -numberOfDays $numberOfDays -numberOfUniqueDates $uniqueDates.Length.ToString() -color $color -rating $rating 220 | } 221 | else 222 | { 223 | return GetFormattedMarkdownForNoResult -workflows $workflows -numberOfDays $numberOfDays 224 | } 225 | } 226 | 227 | #Generate the authorization header for the PowerShell call to the GitHub API 228 | #warning: PowerShell has really wacky return semantics - all output is captured, and returned 229 | #reference: https://stackoverflow.com/questions/10286164/function-return-value-in-powershell 230 | function GetAuthHeader ([string] $patToken, [string] $actionsToken, [string] $appId, [string] $appInstallationId, [string] $appPrivateKey) 231 | { 232 | #Clean the string - without this the PAT TOKEN doesn't process 233 | $patToken = $patToken.Trim() 234 | #Write-Host $appId 235 | #Write-Host "pattoken: $patToken" 236 | #Write-Host "app id is something: $(![string]::IsNullOrEmpty($appId))" 237 | #Write-Host "patToken is something: $(![string]::IsNullOrEmpty($patToken))" 238 | if (![string]::IsNullOrEmpty($patToken)) 239 | { 240 | Write-Host "Authentication detected: PAT TOKEN" 241 | $base64AuthInfo = [System.Convert]::ToBase64String([System.Text.Encoding]::UTF8.GetBytes(":$patToken")) 242 | $authHeader = @{Authorization=("Basic {0}" -f $base64AuthInfo)} 243 | } 244 | elseif (![string]::IsNullOrEmpty($actionsToken)) 245 | { 246 | Write-Host "Authentication detected: GITHUB TOKEN" 247 | $authHeader = @{Authorization=("Bearer {0}" -f $actionsToken)} 248 | } 249 | elseif (![string]::IsNullOrEmpty($appId)) # GitHup App auth 250 | { 251 | Write-Host "Authentication detected: GITHUB APP TOKEN" 252 | $token = Get-JwtToken $appId $appInstallationId $appPrivateKey 253 | $authHeader = @{Authorization=("token {0}" -f $token)} 254 | } 255 | else 256 | { 257 | Write-Host "No authentication detected" 258 | $base64AuthInfo = $null 259 | $authHeader = $null 260 | } 261 | 262 | return $authHeader 263 | } 264 | 265 | function ConvertTo-Base64UrlString( 266 | [Parameter(Mandatory=$true,ValueFromPipeline=$true)]$in) 267 | { 268 | if ($in -is [string]) { 269 | return [Convert]::ToBase64String([System.Text.Encoding]::UTF8.GetBytes($in)) -replace '\+','-' -replace '/','_' -replace '=' 270 | } 271 | elseif ($in -is [byte[]]) { 272 | return [Convert]::ToBase64String($in) -replace '\+','-' -replace '/','_' -replace '=' 273 | } 274 | else { 275 | throw "GitHub App authenication error: ConvertTo-Base64UrlString requires string or byte array input, received $($in.GetType())" 276 | } 277 | } 278 | 279 | function Get-JwtToken([string] $appId, [string] $appInstallationId, [string] $appPrivateKey) 280 | { 281 | # Write-Host "appId: $appId" 282 | $now = (Get-Date).ToUniversalTime() 283 | $createDate = [Math]::Floor([decimal](Get-Date($now) -UFormat "%s")) 284 | $expiryDate = [Math]::Floor([decimal](Get-Date($now.AddMinutes(4)) -UFormat "%s")) 285 | $rawclaims = [Ordered]@{ 286 | iat = [int]$createDate 287 | exp = [int]$expiryDate 288 | iss = $appId 289 | } | ConvertTo-Json 290 | # Write-Host "expiryDate: $expiryDate" 291 | # Write-Host "rawclaims: $rawclaims" 292 | 293 | $Header = [Ordered]@{ 294 | alg = "RS256" 295 | typ = "JWT" 296 | } | ConvertTo-Json 297 | # Write-Host "Header: $Header" 298 | $base64Header = ConvertTo-Base64UrlString $Header 299 | # Write-Host "base64Header: $base64Header" 300 | $base64Payload = ConvertTo-Base64UrlString $rawclaims 301 | # Write-Host "base64Payload: $base64Payload" 302 | 303 | $jwt = $base64Header + '.' + $base64Payload 304 | $toSign = [System.Text.Encoding]::UTF8.GetBytes($jwt) 305 | 306 | $rsa = [System.Security.Cryptography.RSA]::Create(); 307 | # https://stackoverflow.com/a/70132607 lead to the right import 308 | $rsa.ImportRSAPrivateKey([System.Convert]::FromBase64String($appPrivateKey), [ref] $null); 309 | 310 | try { $sig = ConvertTo-Base64UrlString $rsa.SignData($toSign,[Security.Cryptography.HashAlgorithmName]::SHA256,[Security.Cryptography.RSASignaturePadding]::Pkcs1) } 311 | catch { throw New-Object System.Exception -ArgumentList ("GitHub App authenication error: Signing with SHA256 and Pkcs1 padding failed using private key $($rsa): $_", $_.Exception) } 312 | $jwt = $jwt + '.' + $sig 313 | # send headers 314 | $uri = "https://api.github.com/app/installations/$appInstallationId/access_tokens" 315 | $jwtHeader = @{ 316 | Accept = "application/vnd.github+json" 317 | Authorization = "Bearer $jwt" 318 | } 319 | $tokenResponse = Invoke-RestMethod -Uri $uri -Headers $jwtHeader -Method Post -ErrorAction Stop 320 | # Write-Host $tokenResponse.token 321 | return $tokenResponse.token 322 | } 323 | 324 | # Format output for deployment frequency in markdown 325 | function GetFormattedMarkdown([array] $workflowNames, [string] $rating, [string] $displayMetric, [string] $displayUnit, [string] $repo, [string] $branch, [string] $numberOfDays, [string] $numberOfUniqueDates, [string] $color) 326 | { 327 | $encodedString = [uri]::EscapeUriString($displayMetric + " " + $displayUnit) 328 | #double newline to start the line helps with formatting in GitHub logs 329 | $markdown = "`n`n![Deployment Frequency](https://img.shields.io/badge/frequency-" + $encodedString + "-" + $color + "?logo=github&label=Deployment%20frequency)`n" + 330 | "**Definition:** For the primary application or service, how often is it successfully deployed to production.`n" + 331 | "**Results:** Deployment frequency is **$displayMetric $displayUnit** with a **$rating** rating, over the last **$numberOfDays days**.`n" + 332 | "**Details**:`n" + 333 | "- Repository: $repo using $branch branch`n" + 334 | "- Workflow(s) used: $($workflowNames -join ", ")`n" + 335 | "- Active days of deployment: $numberOfUniqueDates days`n" + 336 | "---" 337 | return $markdown 338 | } 339 | 340 | function GetFormattedMarkdownForNoResult([string] $workflows, [string] $numberOfDays) 341 | { 342 | #double newline to start the line helps with formatting in GitHub logs 343 | $markdown = "`n`n![Deployment Frequency](https://img.shields.io/badge/frequency-none-lightgrey?logo=github&label=Deployment%20frequency)`n`n" + 344 | "No data to display for $ownerRepo for workflow(s) $workflows over the last $numberOfDays days`n`n" + 345 | "---" 346 | return $markdown 347 | } 348 | 349 | main -ownerRepo $ownerRepo -workflows $workflows -branch $branch -numberOfDays $numberOfDays -patToken $patToken -actionsToken $actionsToken -appId $appId -appInstallationId $appInstallationId -appPrivateKey $appPrivateKey 350 | --------------------------------------------------------------------------------