From 574f1b03c2e52b5ec5753bad88887c51192966b7 Mon Sep 17 00:00:00 2001 From: Yusheng Li Date: Tue, 29 Aug 2023 15:06:35 +0800 Subject: [PATCH] chore(changelog): new way to maintain the changelog (#11279) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Summary Maintaining changelogs in a single markdown file in the repo is one of the easy ways to maintain and also keep consistency. Since Kong has multiple release versions, sometimes a bug fix needs to be backported to all the supported versions after it gets merged to the master branch. The backport bot is currently broken because of the git conflict. We're Introducing a new way to maintain Kong's changelog, which makes the changelog item become an individual file. The idea is, that you don't get the conflict if you don't edit the same file. --------- Co-authored-by: Hans Hübner Co-authored-by: Wangchong Zhou (cherry picked from commit 17c971b51a588d26c0c85dce11784ce516971024) --- .github/PULL_REQUEST_TEMPLATE.md | 2 +- .github/workflows/build_and_test.yml | 1 + .github/workflows/changelog.yaml | 43 ++++ CHANGELOG/Makefile | 3 + CHANGELOG/README.md | 88 ++++++++ CHANGELOG/changelog | 292 +++++++++++++++++++++++++++ CHANGELOG/changelog-md-template.lua | 63 ++++++ CHANGELOG/changelog-template.yaml | 5 + CHANGELOG/schema.json | 66 ++++++ CHANGELOG/unreleased/kong/.gitkeep | 0 10 files changed, 562 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/changelog.yaml create mode 100644 CHANGELOG/Makefile create mode 100644 CHANGELOG/README.md create mode 100755 CHANGELOG/changelog create mode 100644 CHANGELOG/changelog-md-template.lua create mode 100644 CHANGELOG/changelog-template.yaml create mode 100644 CHANGELOG/schema.json create mode 100644 CHANGELOG/unreleased/kong/.gitkeep diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index 46a80564b092..ee9eb0cb949f 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -11,7 +11,7 @@ https://github.com/Kong/kong/blob/master/CONTRIBUTING.md#contributing ### Checklist - [ ] The Pull Request has tests -- [ ] There's an entry in the CHANGELOG +- [ ] A changelog file has been added to `CHANGELOG/unreleased/kong` or adding `skip-changelog` label on PR if unnecessary. [README.md](https://github.com/Kong/kong/CHANGELOG/README.md) (Please ping @vm-001 if you need help) - [ ] There is a user-facing docs PR against https://github.com/Kong/docs.konghq.com - PUT DOCS PR HERE ### Full changelog diff --git a/.github/workflows/build_and_test.yml b/.github/workflows/build_and_test.yml index 0221ff27b730..cf246d8996ac 100644 --- a/.github/workflows/build_and_test.yml +++ b/.github/workflows/build_and_test.yml @@ -5,6 +5,7 @@ on: # ignore markdown files (CHANGELOG.md, README.md, etc.) - '**/*.md' - '.github/workflows/release.yml' + - 'CHANGELOG/**' push: paths-ignore: # ignore markdown files (CHANGELOG.md, README.md, etc.) diff --git a/.github/workflows/changelog.yaml b/.github/workflows/changelog.yaml new file mode 100644 index 000000000000..9d0e48c27a86 --- /dev/null +++ b/.github/workflows/changelog.yaml @@ -0,0 +1,43 @@ +name: Changelog + +on: + pull_request: + types: [ "opened", "synchronize", "labeled", "unlabeled" ] + +jobs: + require-changelog: + name: Is changelog required? + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v3 + with: + fetch-depth: 2 + + - name: Retrives changed files in CHANGELOG/unreleased/**/*.yaml + id: changelog-check + uses: tj-actions/changed-files@5817a9efb0d7cc34b917d8146ea10b9f32044968 # v37 + with: + files: 'CHANGELOG/unreleased/**/*.yaml' + + - name: Requires a changelog file if 'skip-changelog' label is not added + if: ${{ !contains(github.event.*.labels.*.name, 'skip-changelog') }} + run: > + if [ "${{ steps.changelog-check.outputs.added_files_count }}" = "0" ]; then + echo "PR should contain a changelog file" + exit 1 + fi + + validate-changelog: + name: Validate changelog + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v3 + + - name: Validate changelogs + uses: thiagodnf/yaml-schema-checker@228a5be72029114e3cd6301e0aaeef6b557fa033 # v0.0.8 + with: + jsonSchemaFile: CHANGELOG/schema.json + yamlFiles: | + CHANGELOG/unreleased/*/*.yaml diff --git a/CHANGELOG/Makefile b/CHANGELOG/Makefile new file mode 100644 index 000000000000..a71e38b41106 --- /dev/null +++ b/CHANGELOG/Makefile @@ -0,0 +1,3 @@ +install_dependencies: + luarocks install penlight --local + luarocks install lyaml --local diff --git a/CHANGELOG/README.md b/CHANGELOG/README.md new file mode 100644 index 000000000000..ead8a94074c7 --- /dev/null +++ b/CHANGELOG/README.md @@ -0,0 +1,88 @@ +# CHANGELOG + +The CHANGELOG directory is used for individual changelog file practice. +The `kong/CHANGELOG.md` now is deprecated. + + +## How to add a changelog file for your PR? + +1/ Copy the `changelog-template.yaml` file and rename with your PR number or a short message as the filename. For example, `11279.yaml`, `introduce-a-new-changelog-system.yaml`. (Prefer using PR number as it's already unique and wouldn't introduce conflict) + +2/ Fill out the changelog template. + + +The description of the changelog file field, please follow the `schema.json` for more details. + +- message: Message of the changelog +- type: Changelog type. (`feature`, `bugfix`, `dependency`, `deprecation`, `breaking_change`) +- scope: Changelog scope. (`Core`, `Plugin`, `PDK`, `Admin API`, `Performance`, `Configuration`, `Clustering`) +- prs: List of associated GitHub PRs +- issues: List of associated GitHub issues +- jiras: List of associated Jira tickets for internal track + +Sample 1 +```yaml +message: Introduce the request id as core feature. +type: feat +scope: Core +prs: + - 11308 +``` + +Sample 2 +```yaml +message: Fix response body gets repeated when `kong.response.get_raw_body()` is called multiple times in a request lifecycle. +type: bugfix +scope: PDK +prs: + - 11424 +jiras: + - "FTI-5296" +``` + + +## changelog command + +The `changelog` command tool provides `preview`, and `release` commands. + +### Prerequisites + +You can skip this part if you're at Kong Bazel virtual env. + +Install luajit + +Install luarocks libraries + +``` +luarocks install penlight --local +luarocks install lyaml --local +``` + +### Usage + +```shell +$ ./changelog -h + +Usage: changelog [options] + +Commands: + release release a release note based on the files in the CHANGELOG/unreleased directory. + preview preview a release note based on the files in the CHANGELOG/unreleased directory. + +Options: + -h, --help display help for command + +Examples: + changelog preview 1.0.0 + changelog release 1.0.0 +``` + +**Preview a release note** +```shell +./changelog preview 1.0.0 +``` + +**Release a release note** +```shell +./changelog release 1.0.0 +``` diff --git a/CHANGELOG/changelog b/CHANGELOG/changelog new file mode 100755 index 000000000000..87e93e2c46d8 --- /dev/null +++ b/CHANGELOG/changelog @@ -0,0 +1,292 @@ +#!/usr/bin/env luajit + +local pl_template = require "pl.template" +local pl_tablex = require "pl.tablex" +local pl_file = require "pl.file" +local pl_dir = require "pl.dir" +local pl_path = require "pl.path" +local pl_stringx = require "pl.stringx" +local lyaml = require "lyaml" +local pl_app = require 'pl.lapp' + +local CHANGELOG_PATH -- absolute path of CHANGELOG directory +do + local base_path = os.getenv("PWD") + local command = debug.getinfo(1, "S").source:sub(2) + local last_idx = pl_stringx.rfind(command, "/") + if last_idx then + base_path = pl_path.join(base_path, string.sub(command, 1, last_idx - 1)) + end + CHANGELOG_PATH = base_path +end +local UNRELEASED = "unreleased" +local REPOS = { + kong = "Kong/kong", +} +local JIRA_BASE_URL = "https://konghq.atlassian.net/browse/" +local GITHUB_REFERENCE = { + pr = "https://github.com/%s/pull/%d", + issue = "https://github.com/%s/issues/%d" +} +local SCOPE_PRIORITY = { -- smallest on top + Performance = 10, + Configuration = 20, + Core = 30, + PDK = 40, + Plugin = 50, + ["Admin API"] = 60, + Clustering = 70, + Default = 100, -- default priority +} + +setmetatable(SCOPE_PRIORITY, { + __index = function() + return rawget(SCOPE_PRIORITY, "Default") - 1 + end +}) + +local function table_keys(t) + if type(t) ~= "table" then + return t + end + local keys = {} + for k, _ in pairs(t) do + table.insert(keys, k) + end + return keys +end + +local function parse_github_ref(system, reference_type, references) + if references == nil or references == lyaml.null then + return nil + end + local parsed_references = {} + for i, ref in ipairs(references or {}) do + local repo = REPOS[system] + local ref_no = tonumber(ref) -- treat ref as number string first + local name = "#" .. ref + if not ref_no then -- ref is not a number string + local parts = pl_stringx.split(ref, ":") + repo = parts[1] + ref_no = parts[2] + name = pl_stringx.replace(tostring(ref), ":", " #") + end + parsed_references[i] = { + id = ref_no, + name = name, + link = string.format(GITHUB_REFERENCE[reference_type], repo, ref_no), + } + end + return parsed_references +end + + +local function parse_jiras(jiras) + local jira_items = {} + for i, jira in ipairs(jiras or {}) do + jiras[i] = { + id = jira, + link = JIRA_BASE_URL .. jira + } + end + return jira_items +end + + +local function is_yaml(filename) + return pl_stringx.endswith(filename, ".yaml") or + pl_stringx.endswith(filename, ".yml") +end + +local function is_empty_table(t) + return next(t) == nil +end + +local function compile_template(data, template) + local compile_env = { + _escape = ">", + _brackets = "{}", + _debug = true, + pairs = pairs, + ipairs = ipairs, + tostring = tostring, + is_empty_table = is_empty_table, + } + + compile_env = pl_tablex.merge(compile_env, data, true) -- union + local content, err = pl_template.substitute(template, compile_env) + if not content then + return nil, "failed to compile template: " .. err + end + + return content +end + +local function absolute_path(...) + local path = CHANGELOG_PATH + for _, p in ipairs({...}) do + path = pl_path.join(path, p) + end + return path +end + +local function collect_files(folder) + local files + if pl_path.exists(folder) then + files = assert(pl_dir.getfiles(folder)) + if files then + table.sort(files) + end + end + local sorted_files = {} + for _, filename in ipairs(files or {}) do + if is_yaml(filename) then + table.insert(sorted_files, filename) + end + end + + return sorted_files +end + + +local function collect_folder(system, folder) + local data = { + features = {}, + bugfixes = {}, + breaking_changes = {}, + dependencies = {}, + deprecations = {}, + } + + local map = { + feature = "features", + bugfix = "bugfixes", + breaking_change = "breaking_changes", + dependency = "dependencies", + deprecation = "deprecations", + } + + local files = collect_files(folder) + for _, filename in ipairs(files) do + local content = assert(pl_file.read(filename)) + local entry = assert(lyaml.load(content)) + + entry.prs = parse_github_ref(system, "pr", entry.prs) or {} + entry.issues = parse_github_ref(system, "issue", entry.issues) or {} + entry.jiras = parse_jiras(entry.jiras) or {} + + if entry.scope == nil or entry.scope == lyaml.null then + entry.scope = "Default" + end + + local key = map[entry.type] + if not data[key][entry.scope] then + data[key][entry.scope] = {} + end + table.insert(data[key][entry.scope], entry) + end + + for _, scopes in pairs(data) do + local scope_names = table_keys(scopes) + table.sort(scope_names, function(a, b) return SCOPE_PRIORITY[a] < SCOPE_PRIORITY[b] end) + scopes.sorted_scopes = scope_names + end + + return data +end + +local function collect_unreleased() + local data = {} + + data.kong = collect_folder("kong", absolute_path(UNRELEASED, "kong")) + + return data +end + + +local function generate_content(data) + local template_path = absolute_path("changelog-md-template.lua") + local content = assert(pl_file.read(template_path)) + local changelog_template = assert(loadstring(content))() + return compile_template(data, changelog_template) +end + + +-- command: release +-- release a release note +local function release(version) + -- mkdir unreleased path if not exists + if not pl_path.exists(absolute_path(UNRELEASED)) then + assert(pl_dir.makepath(absolute_path(UNRELEASED))) + end + + local data = collect_unreleased() + data.version = version + local content = assert(generate_content(data)) + local target_path = absolute_path(version) + if pl_path.exists(target_path) then + error("directory exists, please manually remove. " .. version) + end + os.execute("mv " .. UNRELEASED .. " " .. target_path) + local filename = pl_path.join(target_path, "changelog.md") + assert(pl_file.write(filename, content)) + assert(pl_dir.makepath(UNRELEASED)) + + print("Successfully generated release note.") +end + + +-- command: preview +-- preview the release note +local function preview(version) + local data = collect_unreleased() + data.version = version + local content = assert(generate_content(data)) + print(content) +end + + +local cmds = { + release = function(args) + local version = args[1] + if not version then + error("Missing version") + end + release(version) + end, + preview = function(args) + local version = args[1] + if not version then + error("Missing version") + end + preview(version) + end, +} + + +local args = pl_app [[ +Usage: changelog [options] + +Commands: + release release a release note based on the files in the CHANGELOG/unreleased directory. + preview preview a release note based on the files in the CHANGELOG/unreleased directory. + +Options: + -h, --help display help for command + +Examples: + changelog preview 1.0.0 + changelog release 1.0.0 +]] + +local cmd_name = table.remove(args, 1) +if not cmd_name then + pl_app.quit() +end + +local cmd_fn = cmds[cmd_name] +if not cmds[cmd_name] then + pl_app.quit("Invalid command: " .. cmd_name, true) +end + +cmd_fn(args) diff --git a/CHANGELOG/changelog-md-template.lua b/CHANGELOG/changelog-md-template.lua new file mode 100644 index 000000000000..b631139c2657 --- /dev/null +++ b/CHANGELOG/changelog-md-template.lua @@ -0,0 +1,63 @@ +return [[ +> local function render_changelog_entry(entry) +- ${entry.message} +> if #(entry.prs or {}) > 0 then +> for _, pr in ipairs(entry.prs or {}) do + [${pr.name}](${pr.link}) +> end +> end +> if entry.jiras then +> for _, jira in ipairs(entry.jiras or {}) do + [${jira.id}](${jira.link}) +> end +> end +> if #(entry.issues or {}) > 0 then +(issue: +> for _, issue in ipairs(entry.issues or {}) do + [${issue.name}](${issue.link}) +> end +) +> end +> end +> +> local function render_changelog_entries(entries) +> for _, entry in ipairs(entries or {}) do +> render_changelog_entry(entry) +> end +> end +> +> local function render_changelog_section(section_name, t) +> if #t.sorted_scopes > 0 then +### ${section_name} + +> end +> for _, scope_name in ipairs(t.sorted_scopes or {}) do +> if not (#t.sorted_scopes == 1 and scope_name == "Default") then -- do not print the scope_name if only one scope and it's Default scope +#### ${scope_name} + +> end +> render_changelog_entries(t[scope_name]) +> end +> end +> +> +> +# ${version} + +## Kong + +> render_changelog_section("Breaking Changes", kong.breaking_changes) + + +> render_changelog_section("Deprecations", kong.deprecations) + + +> render_changelog_section("Dependencies", kong.dependencies) + + +> render_changelog_section("Features", kong.features) + + +> render_changelog_section("Fixes", kong.bugfixes) + +]] diff --git a/CHANGELOG/changelog-template.yaml b/CHANGELOG/changelog-template.yaml new file mode 100644 index 000000000000..f2594e2911b5 --- /dev/null +++ b/CHANGELOG/changelog-template.yaml @@ -0,0 +1,5 @@ +message: +type: +prs: +jiras: +issues: diff --git a/CHANGELOG/schema.json b/CHANGELOG/schema.json new file mode 100644 index 000000000000..3a84124a19cb --- /dev/null +++ b/CHANGELOG/schema.json @@ -0,0 +1,66 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "properties": { + "message": { + "type": "string", + "description": "Message of the changelog", + "minLength": 1, + "maxLength": 1000 + }, + "type": { + "type": "string", + "description": "Changelog type", + "enum": [ + "feature", + "bugfix", + "dependency", + "deprecation", + "breaking_change" + ] + }, + "scope": { + "type": "string", + "description": "Changelog scope", + "enum": [ + "Core", + "Plugin", + "PDK", + "Admin API", + "Performance", + "Configuration", + "Clustering" + ] + }, + "prs": { + "type": "array", + "description": "List of associated GitHub PRs", + "items": { + "pattern": "^(\\d+|\\w+\/\\w+:\\d+)$", + "type": ["integer", "string"], + "examples": ["1", "torvalds/linux:1"] + } + }, + "issues": { + "type": "array", + "description": "List of associated GitHub issues", + "items": { + "pattern": "^(\\d+|\\w+\/\\w+:\\d+)$", + "type": ["integer", "string"], + "examples": ["1", "torvalds/linux:1"] + } + }, + "jiras": { + "type": "array", + "description": "List of associated Jira tickets for internal tracking.", + "items": { + "type": "string", + "pattern": "^[A-Z]+-[0-9]+$" + } + } + }, + "required": [ + "message", + "type" + ] +} diff --git a/CHANGELOG/unreleased/kong/.gitkeep b/CHANGELOG/unreleased/kong/.gitkeep new file mode 100644 index 000000000000..e69de29bb2d1