diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 00000000..e330fcaa --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,7 @@ + + +## Changelog entry +``` +TODO: Replace this inner text with a useful message +for users of the affected modules! +``` diff --git a/.github/workflows/monorepo-release.yml b/.github/workflows/monorepo-release.yml new file mode 100644 index 00000000..52d474a7 --- /dev/null +++ b/.github/workflows/monorepo-release.yml @@ -0,0 +1,33 @@ +name: 'Monorepo: Release' +on: + repository_dispatch: + types: [monorepo_release] + +jobs: + + tag: + runs-on: ubuntu-latest + name: 'Tag Releases' + permissions: + contents: write + steps: + - uses: actions/github-script@v7 + env: + CLIENT_PAYLOAD: ${{ toJSON(github.event.client_payload) }} + with: + script: | + const { sha, releases } = JSON.parse(process.env.CLIENT_PAYLOAD); + + for (const release of releases) { + const tagName = `${release.module}-${release.newVersion}`; + + const ref = `refs/tags/${tagName}`; + console.log('Tagging', tagName, 'as', sha); + console.log('Owner', context.repo.owner, 'repo', context.repo.repo); + await github.rest.git.createRef({ + owner: context.repo.owner, + repo: context.repo.repo, + ref: ref, + sha: sha + }); + } diff --git a/.github/workflows/monorepo.yml b/.github/workflows/monorepo.yml new file mode 100644 index 00000000..b082a4a3 --- /dev/null +++ b/.github/workflows/monorepo.yml @@ -0,0 +1,331 @@ +name: Monorepo Versioning +concurrency: #avoid concurrent runs on label events, might cause issues on super fast commits ¯\_(ツ)_/¯ + group: ${{ github.head_ref }} + cancel-in-progress: true + +on: + pull_request: + types: [opened, synchronize, reopened, labeled, unlabeled] + pull_request_target: + types: [closed] + +permissions: + pull-requests: read + +jobs: + detect: + runs-on: ubuntu-latest + name: 'Detect pull request context' + outputs: + directories: ${{ steps.condense.outputs.result }} + release-type: ${{ steps.check_pr_label.outputs.release-type}} + is-merge-event: >- + ${{ github.event_name == 'pull_request_target' + && github.event.action == 'closed' + && github.event.pull_request.merged == true }} + steps: + # I'm getting the labels from the API and not the context("contains(github.event.pull_request.labels.*.name, 'Env Promote')") as the labels + # are added in 2nd API call so they aren't included in the PR context + - name: Check PR labels + id: check_pr_label + env: + PR_URL: ${{github.event.pull_request.html_url}} + GITHUB_TOKEN: ${{secrets.GITHUB_TOKEN}} + run: | + LABELS=$(gh pr view $PR_URL --json labels --jq '.labels[] | select((.name=="minor") or (.name=="major") or (.name=="patch") or (.name=="no-release")) |.name') + NUMBER_OF_LABELS=$(echo "$LABELS" | wc -w) + if [ "$NUMBER_OF_LABELS" -eq "1" ] ; then + echo "Found: $LABELS" + echo "release-type=$LABELS" >> "$GITHUB_OUTPUT" + elif [ "$NUMBER_OF_LABELS" -gt "1" ] ; then + echo "::error ::Too many release type labels: $( echo $LABELS | tr '\n' ' ' )" + exit 1 + else + echo "::error ::No release type labels found(patch/minor/major/no-release)" + exit 2 + fi + - name: Get changed files + uses: tj-actions/changed-files@v45 + id: raw-files + with: + json: "true" + escape_json: "false" + - name: Condense to directory list + uses: actions/github-script@v7 + id: condense + env: + RAW_FILES: '${{ steps.raw-files.outputs.all_changed_files }}' + with: + script: | + const raw = JSON.parse(process.env.RAW_FILES); + const directories = Array.from(new Set(raw + .filter(x => !x.startsWith('.')) + .filter(x => x.includes('/')) + .map(x => x.split('/')[0]) + )); + if (directories.length < 1) return {}; + return { + include: directories.map(directory => ({ directory })), + }; + + plan: + needs: detect + if: ${{ needs.detect.outputs.directories != '{}' }} + runs-on: ubuntu-latest + permissions: + contents: read + pull-requests: read + strategy: + matrix: "${{ fromJson(needs.detect.outputs.directories) }}" + fail-fast: false + name: 'Module: ${{ matrix.directory }}' + steps: + - name: Clone repository + uses: actions/checkout@v4 + with: + ref: ${{ github.sha }} + fetch-depth: 0 + - name: Detect previous version number + id: prev-version + env: + PACKAGE_NAME: '${{ matrix.directory }}' + run: | + git fetch --tags + TAG=$(git for-each-ref --sort=-creatordate --count 1 --format="%(refname:short)" "refs/tags/$PACKAGE_NAME-[0-9].[0-9].[0-9]") + + if [ -z "$TAG" ] ; then + echo "No git tag found for $PACKAGE_NAME, using 0.0.0 as previous version" + echo "result=0.0.0" >> "$GITHUB_OUTPUT" + exit 0 + fi + + TAG_VERSION="${TAG#*-}" + echo "TAG_VERSION = $TAG_VERSION" + SEMVER_REGEX="^[0-9].[0-9].[0-9]$" + if [[ $TAG_VERSION =~ $SEMVER_REGEX ]] ; then + echo "$TAG is valid semver, using it" + echo "result=${TAG_VERSION}" >> "$GITHUB_OUTPUT" + exit 0 + else + echo "Error: $TAG does not end in a valid semver" + exit 1 + fi + + - name: Determine new version number + uses: actions/github-script@v7 + id: new-version + env: + PREV_VERSION: ${{ steps.prev-version.outputs.result }} + RELEASE_TYPE: ${{ needs.detect.outputs.release-type }} + with: + script: | + const { PREV_VERSION, RELEASE_TYPE } = process.env; + console.log('Previous version was', PREV_VERSION); + console.log('Release type is', RELEASE_TYPE); + + const numbers = PREV_VERSION.split('.'); + const numberIdx = ['major', 'minor', 'patch'].indexOf(RELEASE_TYPE); + numbers[numberIdx] = parseInt(numbers[numberIdx]) + 1; + for (let i = numberIdx + 1; i < numbers.length; i++) { + numbers[i] = 0; + } + return numbers.join('.'); + result-encoding: string + + - name: Store version numbers + env: + PR_URL: ${{github.event.pull_request.html_url}} + GITHUB_TOKEN: ${{secrets.GITHUB_TOKEN}} + run: | + mkdir output + echo '${{ steps.prev-version.outputs.result }}' > output/previous-version.txt + echo '${{ steps.new-version.outputs.result }}' > output/new-version.txt + + - name: Extract changelog entry + uses: actions/github-script@v7 + with: + script: | + const { owner, repo } = context.repo; + const { data: prInfo } = await github.rest.pulls.get({ + owner, repo, + pull_number: context.issue.number, + }); + console.log('Found PR body:|'); + console.log(prInfo.body); + + const changelogEntry = ((prInfo.body + .split(/^#+ ?/m) + .find(x => x.startsWith('Changelog')) + || '').split(/^```/m)[1] || '').trim(); + if (!changelogEntry) + throw `'Changelog' section not found in PR body! Please add it back.`; + if (changelogEntry.match(/^TODO:/m)) + throw `'Changelog' section needs proper text, instead of 'TODO'`; + + const { writeFile } = require('fs').promises; + await writeFile('output/changelog.md', ` + * PR [#${ prInfo.number }](${ prInfo.html_url }) - ${ prInfo.title } + + \`\`\` + ${changelogEntry} + \`\`\` + `.trimLeft(), { encoding: 'utf-8' }) + + - name: Document example 'source' line + run: | + cat > output/documentation.md <<'EOF' + # Module Location + To use this module in your Terraform, use the below source value (substitute in a real version for the X's). + + ```hcl + module "my_${{ matrix.directory }}" { + source = "git@github.com:mozilla/terraform-modules.git//${{ matrix.directory }}?ref=${{ matrix.directory }}-X.X.X" + # also any inputs for the module (see below) + } + ``` + + EOF + + - name: Install terraform docs + run: | + wget https://github.com/terraform-docs/terraform-docs/releases/download/v0.18.0/terraform-docs-v0.18.0-linux-amd64.tar.gz \ + --output-document - \ + --progress dot:mega \ + | tar -xvz + - name: Clone repository for analysis + uses: actions/checkout@v4 + with: + ref: ${{ github.sha }} + path: src + - name: Render terraform docs + run: | + echo '# Module Attributes' >> output/documentation.md + ./terraform-docs markdown table \ + --output-file "$(pwd)"/output/documentation.md \ + --sort-by required \ + 'src/${{ matrix.directory }}' + - name: Upload result artifacts + uses: actions/upload-artifact@v4 + with: + name: '${{ matrix.directory }}' + path: output + retention-days: 5 + + + comment: + needs: [detect, plan] + if: needs.detect.outputs.is-merge-event == 'false' + runs-on: ubuntu-latest + name: 'Comment on PR' + permissions: + issues: write + pull-requests: write + steps: + - uses: actions/download-artifact@v4.1.8 + with: + path: outputs + - name: Display structure of downloaded files + run: ls -R + working-directory: outputs + + - uses: actions/github-script@v7 + with: + script: | + const { owner, repo } = context.repo; + const { number: issue_number } = context.issue; + const { readdir, readFile } = require('fs').promises; + const utf8 = { encoding: 'utf-8' }; + + const lines = [ + '# Release plan', '', + '| Directory | Previous version | New version |', + '|--|--|--|', + ]; + const sections = []; + + for (const folder of await readdir('outputs', { withFileTypes: true })) { + if (!folder.isDirectory()) continue; + const readText = (name) => readFile(name, utf8).then(x => x.trim()); + + lines.push('| '+[ + `\`${folder.name}\``, + `${await readText(`outputs/${folder.name}/previous-version.txt`)}`, + `**${await readText(`outputs/${folder.name}/new-version.txt`)}**`, + ].join(' | ')+' |'); + + sections.push(`
Changelog preview: ${folder.name}\n\n${await readText(`outputs/${folder.name}/changelog.md`)}\n
`); + } + + const finalBody = [lines.join('\n'), ...sections].join('\n\n'); + + const {data: allComments} = await github.rest.issues.listComments({ issue_number, owner, repo }); + const ourComments = allComments + .filter(comment => comment.user.login === 'github-actions[bot]') + .filter(comment => comment.body.startsWith(lines[0]+'\n')); + + const latestComment = ourComments.slice(-1)[0]; + if (latestComment && latestComment.body === finalBody) { + console.log('Existing comment is already up to date.'); + return; + } + + const {data: newComment} = await github.rest.issues.createComment({ issue_number, owner, repo, body: finalBody }); + console.log('Posted comment', newComment.id, '@', newComment.html_url); + // Delete all our previous comments + for (const comment of ourComments) { + if (comment.id === newComment.id) continue; + console.log('Deleting previous PR comment from', comment.created_at); + await github.rest.issues.deleteComment({ comment_id: comment.id, owner, repo }); + } + + + trigger-release: + needs: [detect, plan] + if: needs.detect.outputs.is-merge-event == 'true' + runs-on: ubuntu-latest + name: 'Dispatch release event' + permissions: + actions: write + contents: write + steps: + - uses: actions/download-artifact@v4.1.8 + with: + path: outputs + + - name: Combine version information + id: extract-releases + uses: actions/github-script@v7 + with: + script: | + const { readdir, readFile } = require('fs').promises; + const utf8 = { encoding: 'utf-8' }; + const readText = (name) => readFile(name, utf8).then(x => x.trim()); + + const directories = await readdir('outputs', { withFileTypes: true }); + return await Promise.all(directories + .filter(x => x.isDirectory()) + .map(async folder => ({ + module: folder.name, + prevVersion: await readText(`outputs/${folder.name}/previous-version.txt`), + newVersion: await readText(`outputs/${folder.name}/new-version.txt`), + }))); + + - name: Dispatch monorepo_release event + uses: actions/github-script@v7 + env: + RELEASE_LIST: '${{ steps.extract-releases.outputs.result }}' + with: + script: | + const payload = { + run_id: "${{ github.run_id }}", + sha: context.sha, + releases: JSON.parse(process.env.RELEASE_LIST), + }; + console.log('Event payload:', JSON.stringify(payload, null, 2)); + + const { owner, repo } = context.repo; + await github.rest.repos.createDispatchEvent({ + owner, repo, + event_type: 'monorepo_release', + client_payload: payload, + }); diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml deleted file mode 100644 index c63ede1b..00000000 --- a/.github/workflows/release.yaml +++ /dev/null @@ -1,23 +0,0 @@ -name: Release -on: - push: - branches: - - main - -jobs: - release: - name: Release - runs-on: ubuntu-latest - steps: - - name: Checkout - uses: actions/checkout@v3 - with: - fetch-depth: 0 - - name: Setup Node.js - uses: actions/setup-node@v3 - with: - node-version: "lts/*" - - name: Release - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: npx semantic-release@20 diff --git a/.releaserc.yaml b/.releaserc.yaml deleted file mode 100644 index 0c812baa..00000000 --- a/.releaserc.yaml +++ /dev/null @@ -1,7 +0,0 @@ -branches: - - main - -plugins: - - "@semantic-release/commit-analyzer" - - "@semantic-release/release-notes-generator" - - "@semantic-release/github" diff --git a/README.md b/README.md index c4dfae0f..437b6881 100644 --- a/README.md +++ b/README.md @@ -20,49 +20,8 @@ This repository also uses [GitHub Actions](.github/workflows/ci.yaml) to run sta ### Versioning and Releases -Versioning is automated based on [Semantic Versioning](https://semver.org/) using [`semantic-release`](https://github.com/semantic-release/semantic-release). -Release changelogs are automated by enforcing [Conventional Commits](https://www.conventionalcommits.org/en/v1.0.0/) -as a PR check using [`semantic-pull-request`](https://github.com/marketplace/actions/semantic-pull-request). - -Conventional commit convention will be checked on: -* commit message for **PRs with a single commit** -* PR title for **PRs with multiple commits** - -> #### 💡 Tip -> -> Push an empty commit to force `Semantic PR` check on the PR title instead of the commit message if `Semantic PR` -> GitHub Action prevents merging because a commit message does not respect the Conventional Commits specification. -> ```shell -> git commit --allow-empty -m "Semantic PR check" -> ``` - - -Additionally, commit squashing is required before merging for PRs with multiple commits. - -#### Release rules matching -From [`semantic-release/commit-analyzer`](https://github.com/semantic-release/commit-analyzer): - -- Commits with a breaking change will be associated with a `major` release. -- Commits with `type` 'feat' will be associated with a `minor` release. -- Commits with `type` 'fix' will be associated with a `patch` release. -- Commits with `type` 'perf' will be associated with a `patch` release. -- Commits with scope `no-release` will not be associated with a release type even if they have a breaking change or the `type` 'feat', 'fix' or 'perf'. -- Commits with `type` 'style' will not be associated with a release type. -- Commits with `type` 'test' will not be associated with a release type. -- Commits with `type` 'chore' will not be associated with a release type. - - -#### Valid commit messages and PR titles : -The tables below shows which commit message or PR title gets you which release type when `semantic-release` runs (using the default configuration): - -| PR title / commit message | Release type | -|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|---------------------------------------------------------------------------------------------------------------------------------------------| -| `fix: GKE bastion host default notes.` | ~~Patch~~ Fix Release | -| `feat: Copy google-cdn-external from cloudops-infra.` | ~~Minor~~ Feature Release | -| `feat(google_cloudsql_mysql): Add query insights settings.` | ~~Minor~~ Feature Release | -| `refactor!: Drop support for Terraform 0.12.` | ~~Major~~ Breaking Release
(Note that since PR titles only have a single line, you have to use the `!` syntax for breaking changes.) | -| `perf(pencil): remove graphiteWidth option`

`BREAKING CHANGE: The graphiteWidth option has been removed.`
`The default graphite width of 10mm is always used for performance reasons.` | ~~Major~~ Breaking Release
(Note that the `BREAKING CHANGE: ` token must be in the footer of the commit message) | - +Versioning is automated based on [Semantic Versioning](https://semver.org/) with per-module versions utilizing [`PR labels`](https://medium.com/forto-tech-blog/automated-versioning-of-terraform-modules-with-github-actions-semver-style-800f91ed5037). Apply the appropriate major/minor/patch/no-release label to your PR, and Github Actions will handle the rest. +Release changelogs are automatically generated using the contents of the PR description. ## Creating modules @@ -106,23 +65,23 @@ Alternatively, `pre-commit install` on this repository to automatically format t Some examples on using modules in this repository follow. -**Recommended**: Using the (imaginary) `google_gke-cluster` module based on a specific automatically created git tag: +**Recommended**: Using the `google_gke` module based on automatically created git tag: ```terraform module "gke" { - source = "git@github.com:mozilla/terraform-modules.git//google_gke-cluster?ref=google_gke-cluster_v1.0.1" + source = "git@github.com:mozilla/terraform-modules.git//google_gke?ref=google_gke-0.0.1" } ``` -Using the (imaginary) `google_gke-cluster` module with always the latest version (e.g. following main branch): +Using the `google_gke` module with always the latest version (e.g. following main branch): ```terraform module "gke" { - source = "git@github.com:mozilla/terraform-modules.git//google_gke-cluster?ref=main" + source = "git@github.com:mozilla/terraform-modules.git//google_gke?ref=main" } ``` -Using the (imaginary) `google_gke-cluster` module based on a specific (imaginary) git commit: +Using the `google_gke` module based on a specific (imaginary) git commit: ```terraform module "gke" { - source = "git@github.com:mozilla/terraform-modules.git//google_gke-cluster?ref=69ad17030bfa4ea46f68f8cc449102d446658851" + source = "git@github.com:mozilla/terraform-modules.git//google_gke?ref=69ad17030bfa4ea46f68f8cc449102d446658851" } ```