diff --git a/.github/workflows/Linter.yml b/.github/workflows/Linter.yml new file mode 100644 index 0000000..66d3ef4 --- /dev/null +++ b/.github/workflows/Linter.yml @@ -0,0 +1,28 @@ +name: Linter + +run-name: "Linter - [${{ github.event.pull_request.title }} #${{ github.event.pull_request.number }}] by @${{ github.actor }}" + +on: [pull_request] + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read + statuses: write + +jobs: + Lint: + name: Lint code base + runs-on: ubuntu-latest + steps: + - name: Checkout repo + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Lint code base + uses: super-linter/super-linter/slim@latest + env: + GITHUB_TOKEN: ${{ github.token }} diff --git a/.github/workflows/assign.yml b/.github/workflows/assign.yml new file mode 100644 index 0000000..f1ac27d --- /dev/null +++ b/.github/workflows/assign.yml @@ -0,0 +1,48 @@ +name: Create Repo - Assign +on: + issues: + types: + - opened + +permissions: + contents: read + +env: + number_of_assignees: 1 + +jobs: + assign: + name: Assign new issue + runs-on: ubuntu-latest + if: contains(github.event.issue.labels.*.name, 'create-repo') && github.event.action == 'opened' + steps: + - name: Generate app token + uses: actions/create-github-app-token@v1 + id: authenticate + with: + app-id: ${{ secrets.APP_ID }} + private-key: ${{ secrets.APP_PEM }} + + - name: Acknowledge + uses: actions/github-script@v7 + env: + reviewer_team: ${{ vars.reviewer_team }} + with: + github-token: ${{ steps.authenticate.outputs.token }} + script: | + const body = ` + @${context.actor} : Hey! Let's get this issue assigned to someone from the @${context.repo.owner}/${process.env.reviewer_team} team while we validate the request. + ` + + github.rest.issues.createComment({ + ...context.repo, + issue_number: context.issue.number, + body + }) + + - name: Auto-assign issue + uses: pozil/auto-assign-issue@v2.0.0 + with: + repo-token: ${{ steps.authenticate.outputs.token }} + teams: ${{ vars.reviewer_team }} + numOfAssignee: ${{ env.number_of_assignees }} diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..ea7188e --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,81 @@ +name: CI + +on: + workflow_dispatch: + pull_request: + branches: + - main + paths: + # All tf files + - '**/*.tf' + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read + statuses: write + id-token: write + +env: + ARM_CLIENT_ID: ${{ vars.AZURE_CLIENT_ID }} + ARM_TENANT_ID: ${{ vars.AZURE_TENANT_ID }} + GITHUB_TOKEN: ${{ secrets.PAT }} + ARM_USE_OIDC: true + +jobs: + CI: + name: CI + runs-on: ubuntu-latest + environment: prod + steps: + - name: Generate app token + uses: actions/create-github-app-token@v1 + id: authenticate + with: + app-id: ${{ secrets.APP_ID }} + private-key: ${{ secrets.APP_PEM }} + + - uses: actions/checkout@v4 + + - run: terraform init + + - name: Create a speculative plan + id: tfplan + shell: pwsh + run: | + # Create a speculative plan + $plan = terraform plan -lock=false -no-color + Set-Content -Path 'tfplan' -Value $plan -Encoding utf8 + + terraform plan -lock=false + + - name: Write plan in pr comment + uses: actions/github-script@v7 + if: github.event_name == 'pull_request' + env: + plan: ${{ steps.tfplan.outputs.plan }} + with: + github-token: ${{ steps.authenticate.outputs.token }} + script: | + const fs = require('fs') + const fileContent = fs.readFileSync('tfplan', 'utf8') + + const body = ` + Here is the plan for the changes in this PR: + +
+ Terraform Plan + + \`\`\`terraform + ${fileContent} + \`\`\` + + ` + + github.rest.issues.createComment({ + ...context.repo, + issue_number: context.issue.number, + body + }) diff --git a/.github/workflows/fulfill.yml b/.github/workflows/fulfill.yml new file mode 100644 index 0000000..b7b23ef --- /dev/null +++ b/.github/workflows/fulfill.yml @@ -0,0 +1,117 @@ +name: CD + +on: + workflow_dispatch: + push: + branches: + - main + +concurrency: + group: ${{ github.workflow }} + +permissions: + id-token: write # Needed for OIDC auth in Terraform + +env: + ARM_CLIENT_ID: ${{ vars.AZURE_CLIENT_ID }} + ARM_TENANT_ID: ${{ vars.AZURE_TENANT_ID }} + ARM_USE_OIDC: true + +jobs: + CD: + name: CD + runs-on: ubuntu-latest + environment: prod + steps: + - name: Generate app token + uses: actions/create-github-app-token@v1 + id: authenticate + with: + app-id: ${{ secrets.APP_ID }} + private-key: ${{ secrets.APP_PEM }} + owner: ${{ github.repository_owner }} + + - name: Checkout repo + uses: actions/checkout@v4 + with: + fetch-depth: 0 + token: ${{ steps.authenticate.outputs.token }} + + - name: Get PR data + uses: actions/github-script@v7 + if: github.event_name == 'push' + id: get_pr_data + with: + github-token: ${{ steps.authenticate.outputs.token }} + script: | + return ( + await github.rest.repos.listPullRequestsAssociatedWithCommit({ + ...context.repo, + commit_sha: context.sha, + }) + ).data[0] + + - name: Get issue number + shell: pwsh + id: get_issue_data + if: ${{ steps.get_pr_data.outputs.result != null }} + env: + PR_DATA: ${{ steps.get_pr_data.outputs.result }} + run: | + $prdata = $env:PR_DATA | ConvertFrom-Json + $prTitle = $prdata.title + + # Get the issue number from the PR title + if ($prTitle -match '#(\d+)') { + $issueNumber = $matches[1] + Write-Output "Issue number: $issueNumber" + "issue=$true" | Out-File -FilePath $env:GITHUB_OUTPUT -Append + "issueNumber=$issueNumber" | Out-File -FilePath $env:GITHUB_OUTPUT -Append + } else { + Write-Output "No issue number found in the title." + "issue=$false" | Out-File -FilePath $env:GITHUB_OUTPUT -Append + } + + - name: Acknowledge + uses: actions/github-script@v7 + if: ${{ steps.get_issue_data.outputs.issue == 'true' }} + env: + issue_number: ${{ steps.get_issue_data.outputs.issueNumber }} + with: + github-token: ${{ steps.authenticate.outputs.token }} + script: | + const body = ` + @${context.actor} : All looks good! Merged the request now so we can get the repository created for you. + ` + + github.rest.issues.createComment({ + ...context.repo, + issue_number: process.env.issue_number, + body + }) + + - run: terraform init + + - name: Terraform apply + env: + # GITHUB_TOKEN: ${{ steps.authenticate.outputs.token }} # App has issues with GH permissions team lookup and generate repo from template + GITHUB_TOKEN: ${{ secrets.PAT }} + run: terraform apply -auto-approve + + - name: Close initial issue + if: ${{ steps.get_issue_data.outputs.issue == 'true' }} + shell: pwsh + env: + GITHUB_ENTERPRISE_TOKEN: ${{ steps.authenticate.outputs.token }} + ISSUE_NUMBER: ${{ steps.get_issue_data.outputs.issueNumber }} + PR_DATA: ${{ steps.get_pr_data.outputs.result }} + run: | + $prdata = $env:PR_DATA | ConvertFrom-Json + $prNumber = $prdata.number + + '::group::Authenticate to GitHub' + $env:GITHUB_ENTERPRISE_TOKEN | gh auth login --hostname $env:GH_HOST --with-token + gh auth status + + '::group::Close issue' + gh issue close $env:ISSUE_NUMBER --comment "With PR #$prNumber being closed, we can close this issue." --reason completed diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml new file mode 100644 index 0000000..68594e4 --- /dev/null +++ b/.github/workflows/validate.yml @@ -0,0 +1,355 @@ +name: Create Repo - Validate +on: + issues: + types: + - opened + - edited + +permissions: {} + +jobs: + validate: + name: Validate issue + runs-on: ubuntu-latest + if: contains(github.event.issue.labels.*.name, 'create-repo') && github.event.issue.state == 'open' + steps: + - name: Generate app token + uses: actions/create-github-app-token@v1 + id: authenticate + with: + app-id: ${{ secrets.APP_ID }} + private-key: ${{ secrets.APP_PEM }} + + - name: Handle generate app installation token failure + if: ${{ failure() }} + uses: actions/github-script@v7 + with: + script: | + const body = ` + @${context.actor} : Unfortunately, it appears something went wrong in generating app installation token granting. + ` + + github.rest.issues.createComment({ + ...context.repo, + issue_number: context.issue.number, + body + }) + + - name: Retrieve GitHub App User ID + id: app + uses: actions/github-script@v7 + env: + appSlug: ${{ steps.authenticate.outputs.app-slug }} + with: + github-token: ${{ steps.authenticate.outputs.token }} + script: | + const username = `${process.env.appSlug}[bot]` + console.log(`Username: ${username}`) + + const app = (await github.rest.users.getByUsername({ + username: `${username}` + })).data + + console.log(JSON.stringify(app)) + + return app + + - name: Application data + shell: pwsh + env: + DATA: ${{ steps.app.outputs.result }} + run: | + $data = $env:DATA | ConvertFrom-Json + Write-Verbose ($data | Out-String) -Verbose + + - name: Checkout repo + uses: actions/checkout@v4 + with: + fetch-depth: 0 + token: ${{ steps.authenticate.outputs.token }} + + - name: Parse issue form + id: parse + uses: zentered/issue-forms-body-parser@v2.2.0 + + - name: Print parsed data + shell: pwsh + env: + DATA: ${{ steps.parse.outputs.data }} + run: | + $data = $env:DATA | ConvertFrom-Json + Write-Verbose ($data | Out-String) -Verbose + + - name: Record issue form results + if: false + uses: actions/github-script@v7 + env: + ISSUE_FORM_JSON: ${{ steps.parse.outputs.data }} + with: + github-token: ${{ steps.authenticate.outputs.token }} + script: | + const issueForm = JSON.parse(process.env.ISSUE_FORM_JSON) + const jsonPretty = JSON.stringify(issueForm, null, 2) + + const body = ` + Nice! Let's validate the issue form so we can get this thing done! + +
+ Issue Form Payload + + \`\`\`json + ${jsonPretty} + \`\`\` +
+ ` + + github.rest.issues.createComment({ + ...context.repo, + issue_number: context.issue.number, + body + }) + + - name: Verify issue form results + id: inputs + uses: actions/github-script@v7 + env: + ISSUE_FORM_JSON: ${{ steps.parse.outputs.data }} + VALIDATE_FILE: ./scripts/validate.js + with: + github-token: ${{ steps.authenticate.outputs.token }} + script: | + const validate = require(process.env.VALIDATE_FILE) + await validate({github, context, core}) + + - name: Handle verify issue form results failure + if: ${{ failure() }} + uses: actions/github-script@v7 + env: + ERRORS: ${{ steps.inputs.outputs.errors }} + with: + github-token: ${{ steps.authenticate.outputs.token }} + script: | + const errors = JSON.parse(process.env.ERRORS) + const errorsPretty = errors.map(error => `1. ${error}`).join("\r\n") + const body = ` + @${context.actor} : Unfortunately, it appears one or more validation issues arose or something went wrong. + + ${errorsPretty} + ` + + github.rest.issues.createComment({ + ...context.repo, + issue_number: context.issue.number, + body + }) + + - name: Configure git + shell: pwsh + env: + APP_USER_OBJECT: ${{ steps.app.outputs.result }} + run: | + $app = ($env:APP_USER_OBJECT | ConvertFrom-Json) + git config --global user.name "$($app.login)" + git config --global user.email "$($app.id)+$($app.login)@users.noreply.github.com" + git config --global --list + + - name: Build terraform file + id: build + shell: pwsh + env: + GITHUB_TOKEN: ${{ steps.authenticate.outputs.token }} + ISSUE_FORM_JSON: ${{ steps.parse.outputs.data }} + ISSUE_NUMBER: ${{ github.event.issue.number }} + run: | + $branchName = "create-repo-$env:ISSUE_NUMBER" + Write-Host "::group::Create branch [$branchName]" + git fetch + git checkout -b $branchName + $upstreamBranches = git branch -r + Write-Verbose 'Upstream branches' -Verbose + Write-Verbose ($upstreamBranches | Out-String) -Verbose + $branchExists = ($upstreamBranches | ForEach-Object { $_ -Match "origin/$branchName" }) -contains $true + if ($branchExists) { + Write-Verbose "Branch [$branchName] exists on remote, pulling changes." -Verbose + git pull origin $branchName --rebase + } + + Write-Host '::group::Process data from issue' + $repository = $env:ISSUE_FORM_JSON | ConvertFrom-Json -AsHashtable + Write-Verbose ($repository | Out-String) -Verbose + + Write-Host '::group::Build replacement table' + $type = [string]($repository.type.text).ToLower() + + $replacements = @{ + name = $repository.name.text + organization = $repository.organization.text + description = $repository.description.text + visibility = $repository.visibility.text + type = $type + business_application_id = $repository['business-application-id'].text + } + Write-Verbose ($replacements | Out-String) -Verbose + + Write-Host '::group::Build Terraform file' + $filePath = Join-Path -Path $PWD -ChildPath "$($repository.name.text).tf" + $repoFileTemplate = Join-Path -Path 'template' -ChildPath "$type.txt" + $content = Get-Content -Path $repoFileTemplate -Raw + foreach ($item in $replacements.Keys) { + $value = $replacements[$item] + $content = $content.Replace("{{ $item }}", $value) + } + $content | Set-Content -Path $filePath -Force + + Write-Host "::group::File content [$filePath]" + Write-Verbose (Get-Content -Path $filePath -Raw) -Verbose + "file-path=$filePath" | Out-File -FilePath $env:GITHUB_OUTPUT -Append + + Write-Host '::group::Add files' + git add . + git status + Write-Host '::group::Commit changes' + git commit -m "Automated commit" + git status + Write-Host '::group::Push changes' + git push --set-upstream origin $branchName + git status + + Get-ChildItem env: | Format-Table -AutoSize + + - name: PR changes + id: pr + shell: pwsh + env: + GITHUB_ENTERPRISE_TOKEN: ${{ steps.authenticate.outputs.token }} + BRANCH_NAME: create-repo-${{ github.event.issue.number }} + ISSUE_NUMBER: ${{ github.event.issue.number }} + run: | + '::group::Authenticate to GitHub' + $env:GITHUB_ENTERPRISE_TOKEN | gh auth login --hostname $env:GH_HOST --with-token + gh auth status + + '::group::Ensure PR exists' + $pr = gh pr list --state open --base main --head create-repo-${env:ISSUE_NUMBER} --json 'number,title,baseRefName,headRefName' | ConvertFrom-Json + if (-not $pr) { + Write-Host "No PR exists for issue [$env:ISSUE_NUMBER], creation one." + gh pr create --title "Request #$env:ISSUE_NUMBER" --body "Based on request #$env:ISSUE_NUMBER" --base main --head $env:BRANCH_NAME + } + $pr = gh pr list --state open --base main --head create-repo-${env:ISSUE_NUMBER} --json 'number,title,baseRefName,headRefName' | ConvertFrom-Json + if (-not $pr) { + Write-Error "Failed to create PR for issue [$env:ISSUE_NUMBER]" + exit 1 + } + $pr | Select-Object number,title,baseRefName,headRefName | Format-Table -AutoSize + $prNumber = $pr.number + + if (-not $prNumber) { + Write-Error "Failed to create PR for issue [$env:ISSUE_NUMBER]" + exit 1 + } + + "::group::Output PR number [$prNumber] to 'prNumber'" + "prNumber=$prNumber" | Out-File -FilePath $env:GITHUB_OUTPUT -Append + + '::group::Ensure label is added to PR' + gh pr edit $prNumber --add-label create-repo --repo $env:GITHUB_REPOSITORY + + '::group::Ensure actor is assigned to the PR' + gh pr edit $prNumber --add-assignee $env:GITHUB_ACTOR --repo $env:GITHUB_REPOSITORY + + '::group::Ensure PR has auto-merge enabled' + $email = git config --global user.email + # gh pr merge $prNumber --auto --delete-branch --merge --repo $env:GITHUB_REPOSITORY + # gh pr merge $prNumber --auto --delete-branch --squash --repo $env:GITHUB_REPOSITORY --author-email $email + gh pr merge $prNumber --auto --delete-branch --squash --repo $env:GITHUB_REPOSITORY + + - name: Generate app token + uses: actions/create-github-app-token@v1 + id: authenticate_approver + with: + app-id: ${{ secrets.APPROVER_APP_ID }} + private-key: ${{ secrets.APPROVER_APP_PEM }} + + - name: Retrieve GitHub App User ID + id: app_approver + uses: actions/github-script@v7 + env: + appSlug: ${{ steps.authenticate_approver.outputs.app-slug }} + with: + github-token: ${{ steps.authenticate_approver.outputs.token }} + script: | + const username = `${process.env.appSlug}[bot]` + console.log(`Username: ${username}`) + + const app = (await github.rest.users.getByUsername({ + username: `${username}` + })).data + + console.log(JSON.stringify(app)) + + return app + + - name: Application data + shell: pwsh + env: + DATA: ${{ steps.app_approver.outputs.result }} + run: | + $data = $env:DATA | ConvertFrom-Json + Write-Verbose ($data | Out-String) -Verbose + + - name: Approve PR changes + id: pr_approve + shell: pwsh + env: + GITHUB_ENTERPRISE_TOKEN: ${{ steps.authenticate_approver.outputs.token }} + GH_HOST: msx.ghe.com + PR_NUMBER: ${{ steps.pr.outputs.prNumber }} + APP_USER_OBJECT: ${{ steps.app_approver.outputs.result }} + run: | + '::group::Authenticate to GitHub' + $env:GITHUB_ENTERPRISE_TOKEN | gh auth login --hostname $env:GH_HOST --with-token + gh auth status + + '::group::Configure git' + $app = $env:APP_USER_OBJECT | ConvertFrom-Json + git config --global user.name "$($app.login)" + git config --global user.email "$($app.id)+$($app.login)@users.noreply.github.com" + git config --global --list + + $prNumber = $env:PR_NUMBER + '::group::Ensure PR is approved' + gh pr review $prNumber --approve --repo $env:GITHUB_REPOSITORY + + - name: Confirm issue + uses: actions/github-script@v7 + env: + reviewer_team: ${{ vars.reviewer_team }} + pr_number: ${{ steps.pr.outputs.prNumber }} + with: + github-token: ${{ steps.authenticate.outputs.token }} + script: | + const fs = require('fs') + const filePath = '${{ steps.build.outputs.file-path }}' + const fileName = filePath.split('/').pop() + const fileContent = fs.readFileSync(filePath, 'utf8') + + const body = ` + @${context.actor} : Everything looks good on the surface! We have opened a PR for the repository. + + #${process.env.pr_number} + +
+ Terraform file + + From: [${fileName}] + \`\`\`terraform + ${fileContent} + \`\`\` +
+ ` + + github.rest.issues.createComment({ + ...context.repo, + issue_number: context.issue.number, + body + })