Skip to content

Commit 2bb309f

Browse files
committed
testing validator logic
1 parent 94d5512 commit 2bb309f

3 files changed

Lines changed: 235 additions & 0 deletions

File tree

.github/scripts/validate-changelog

Lines changed: 187 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,187 @@
1+
#!/usr/bin/env python3
2+
"""Check that a pull request adds a changelog fragment.
3+
4+
Reads the files the pull request changed on stdin (one path per line, as
5+
produced by `git diff --name-only`) and exits non-zero when the pull request
6+
changes hand-written SDK sources but adds no fragment under .changelog/.
7+
8+
Fragments accumulate in .changelog/ until a release consumes them, so the check
9+
looks at what THIS pull request added rather than whether the directory is
10+
non-empty.
11+
12+
Usage::
13+
14+
git diff --name-only "$(git merge-base origin/main HEAD)" HEAD \
15+
| python3 validate-changelog --repo-root /path/to/aws-sdk-cpp
16+
"""
17+
import argparse
18+
import json
19+
import os
20+
import sys
21+
22+
# The three constants below mirror tools/scripts/new-change, which lives in the
23+
# SDK repository -- keep them in sync by hand.
24+
VALID_TYPES = [
25+
'feature',
26+
'bugfix',
27+
'deprecation',
28+
'removal',
29+
'documentation',
30+
'dependency',
31+
'breaking-change',
32+
]
33+
34+
REQUIRED_FIELDS = ['type', 'category', 'description']
35+
36+
# Reject anything outside this set: the formatter ignores unknown keys, so a
37+
# misspelled 'descripton' would drop the entry from the changelog with no warning.
38+
KNOWN_FIELDS = REQUIRED_FIELDS + ['contributor']
39+
40+
CHANGELOG_DIR_NAME = '.changelog'
41+
42+
# Path prefixes that require a fragment. Anchored, so 'src/' does not also
43+
# match 'generated/src/'.
44+
BEHAVIOR_PATH_PREFIXES = (
45+
'src/',
46+
'cmake/',
47+
'tools/scripts/',
48+
)
49+
50+
# Checked first, so these win. Generated output and API models get their release
51+
# notes from Trebuchet service metadata, not hand-authored fragments.
52+
EXEMPT_PATH_PREFIXES = (
53+
'generated/',
54+
'tools/code-generation/api-descriptions/',
55+
'tools/code-generation/smithy/api-descriptions/',
56+
'tools/code-generation/endpoints/',
57+
'tools/code-generation/defaults/',
58+
'tools/code-generation/partitions/',
59+
)
60+
61+
62+
def requires_fragment(changed_files):
63+
"""Return the changed files that require a changelog fragment."""
64+
triggering = []
65+
for path in changed_files:
66+
if path.startswith(EXEMPT_PATH_PREFIXES):
67+
continue
68+
if path.startswith(BEHAVIOR_PATH_PREFIXES):
69+
triggering.append(path)
70+
return triggering
71+
72+
73+
def added_fragments(changed_files, repo_root):
74+
"""Return the changed files that are fragments present under .changelog/.
75+
76+
A deleted fragment still shows up in the diff, so filter to paths that
77+
exist on disk -- otherwise deleting a fragment would satisfy the gate.
78+
"""
79+
prefix = CHANGELOG_DIR_NAME + '/'
80+
return [
81+
p for p in changed_files
82+
if p.startswith(prefix) and not os.path.basename(p).startswith('.')
83+
and not os.path.basename(p).upper().startswith('README')
84+
and os.path.isfile(os.path.join(repo_root, p))
85+
]
86+
87+
88+
def validate_fragment(path, repo_root):
89+
"""Validate one fragment. Returns a list of error strings."""
90+
full = os.path.join(repo_root, path)
91+
92+
if not os.path.isfile(full):
93+
# Deleted or renamed by this pull request; nothing to validate.
94+
return []
95+
96+
if not path.endswith('.json'):
97+
return ['%s: fragments must be .json files '
98+
'(run tools/scripts/new-change)' % path]
99+
100+
try:
101+
with open(full) as f:
102+
fragment = json.load(f)
103+
except ValueError as e:
104+
return ['%s: not valid JSON (%s)' % (path, e)]
105+
except OSError as e:
106+
return ['%s: could not be read (%s)' % (path, e)]
107+
108+
if not isinstance(fragment, dict):
109+
return ['%s: must contain a JSON object' % path]
110+
111+
errors = []
112+
for field in REQUIRED_FIELDS:
113+
value = fragment.get(field)
114+
if not isinstance(value, str) or not value.strip():
115+
errors.append(
116+
"%s: missing or empty required field '%s'" % (path, field))
117+
118+
change_type = fragment.get('type')
119+
if isinstance(change_type, str) and change_type.strip() \
120+
and change_type.strip() not in VALID_TYPES:
121+
errors.append("%s: invalid type '%s'. Must be one of: %s"
122+
% (path, change_type.strip(), ', '.join(VALID_TYPES)))
123+
124+
unknown = sorted(set(fragment) - set(KNOWN_FIELDS))
125+
if unknown:
126+
errors.append(
127+
'%s: unrecognized field(s) %s. Expected only: %s. Hand-edited '
128+
'fragments are easy to typo -- prefer tools/scripts/new-change.'
129+
% (path, ', '.join("'%s'" % f for f in unknown),
130+
', '.join(KNOWN_FIELDS)))
131+
132+
return errors
133+
134+
135+
def main():
136+
parser = argparse.ArgumentParser(
137+
description='Check that a pull request adds a changelog fragment')
138+
parser.add_argument(
139+
'--repo-root', default='.',
140+
help='Root of the SDK repository holding %s/. Defaults to the working '
141+
'directory.' % CHANGELOG_DIR_NAME)
142+
args = parser.parse_args()
143+
144+
repo_root = os.path.abspath(args.repo_root)
145+
changed_files = [line.strip() for line in sys.stdin if line.strip()]
146+
147+
fragments = added_fragments(changed_files, repo_root)
148+
149+
# Validate any fragment this pull request touched, even if none was
150+
# required: a malformed fragment should fail here rather than be silently
151+
# dropped when the release assembles the changelog.
152+
errors = []
153+
for path in fragments:
154+
errors.extend(validate_fragment(path, repo_root))
155+
if errors:
156+
sys.stderr.write('Invalid changelog fragment(s):\n')
157+
for error in errors:
158+
sys.stderr.write(' - %s\n' % error)
159+
return 1
160+
161+
triggering = requires_fragment(changed_files)
162+
if not triggering:
163+
print('No changelog fragment required for this change.')
164+
return 0
165+
166+
if not fragments:
167+
sys.stderr.write(
168+
'This pull request changes SDK behavior but adds no changelog '
169+
'fragment.\n\nFiles that require a fragment:\n')
170+
for path in triggering[:20]:
171+
sys.stderr.write(' - %s\n' % path)
172+
if len(triggering) > 20:
173+
sys.stderr.write(' ... and %d more\n' % (len(triggering) - 20))
174+
sys.stderr.write(
175+
'\nRun `tools/scripts/new-change` to generate one, then commit the '
176+
'file it creates in %s/.\n'
177+
'See the Changelog section of CONTRIBUTING.md for details.\n'
178+
% CHANGELOG_DIR_NAME)
179+
return 1
180+
181+
print('Found %d changelog fragment(s): %s'
182+
% (len(fragments), ', '.join(fragments)))
183+
return 0
184+
185+
186+
if __name__ == '__main__':
187+
sys.exit(main())
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
name: Changelog Fragment Check
2+
3+
# Emulates the pull-request path of the internal Catapult ChangelogStep so the
4+
# fragment validator can be exercised on a real GitHub PR (the Catapult build
5+
# cannot be triggered manually). Behavior-affecting changes under src/, cmake/,
6+
# or tools/scripts/ must add a .changelog/*.json fragment.
7+
8+
on:
9+
pull_request:
10+
types: [opened, synchronize, reopened]
11+
12+
permissions:
13+
contents: read
14+
15+
jobs:
16+
changelog-fragment:
17+
runs-on: ubuntu-latest
18+
steps:
19+
- name: Checkout PR (full history for merge-base)
20+
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
21+
with:
22+
fetch-depth: 0
23+
persist-credentials: false
24+
- name: Set up Python
25+
uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0
26+
with:
27+
python-version: '3.x'
28+
- name: Validate changelog fragment
29+
env:
30+
BASE_SHA: ${{ github.event.pull_request.base.sha }}
31+
run: |
32+
set -euo pipefail
33+
# Merge base with the PR's target branch, matching what the Catapult
34+
# ChangelogStep computes from origin/<baseBranch>.
35+
BASE="$(git merge-base "$BASE_SHA" HEAD)"
36+
if [ -z "$BASE" ]; then
37+
echo "changelog: no merge base between HEAD and PR base; cannot determine changed files" >&2
38+
exit 1
39+
fi
40+
echo "Diffing $BASE..HEAD"
41+
git diff --name-only "$BASE" HEAD \
42+
| python3 .github/scripts/validate-changelog --repo-root .

src/CHANGELOG_VALIDATOR_DEMO.txt

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
Placeholder change under src/ used to exercise the changelog fragment
2+
validator (.github/workflows/changelog-fragment-check.yml).
3+
4+
Because this path is under src/, the validator requires the pull request to
5+
also add a .changelog/*.json fragment. Delete this file and the demo branch
6+
once the validator has been verified.

0 commit comments

Comments
 (0)