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 97805dc62d30..2449244a4cb6 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