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"
}
```