Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
93 changes: 93 additions & 0 deletions .github/scripts/public_api.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
"""Prints the SDK's public C++ API as sorted "<qualified name> <kind> <type> <access>" lines, from clang's AST."""
import json
import sys

RECORDS = {"CXXRecordDecl", "ClassTemplateDecl"}
MEMBERS = {
"CXXMethodDecl": "method",
"CXXConstructorDecl": "constructor",
"CXXDestructorDecl": "destructor",
"CXXConversionDecl": "conversion",
"FieldDecl": "field",
"VarDecl": "variable",
"FunctionDecl": "function",
"FunctionTemplateDecl": "function template",
"TypedefDecl": "typedef",
"TypeAliasDecl": "alias",
}


def documents(text):
"""Yields each JSON document in text: clang's -ast-dump-filter writes one per matching declaration."""
decoder = json.JSONDecoder()
position = 0
while True:
while position < len(text) and text[position].isspace():
position += 1
if position >= len(text):
return
document, position = decoder.raw_decode(text, position)
yield document


def signature(node):
"""Returns the type of a declaration with the flags that change its contract, such as virtual."""
if node["kind"] == "FunctionTemplateDecl":
node = next((child for child in node.get("inner", [])
if child.get("kind") in ("FunctionDecl", "CXXMethodDecl")), node)
flags = [flag for flag in ("virtual", "pure", "explicit", "constexpr") if node.get(flag)]
if node.get("storageClass") == "static":
flags.append("static")
return " ".join([node.get("type", {}).get("qualType", "")] + flags).strip()


def walk(node, scope, access, lines):
"""Adds the public declarations under node, declared in scope with the given default member access."""
kind = node.get("kind")
if node.get("isImplicit"):
return
member_access = node.get("access", access)
if kind == "NamespaceDecl":
for child in node.get("inner", []):
walk(child, f"{scope}{node.get('name', '')}::", "public", lines)
return
if member_access not in ("public", "protected"):
return
name = node.get("name")
if kind in RECORDS and name:
record = node
if kind == "ClassTemplateDecl":
record = next((child for child in node.get("inner", [])
if child.get("kind") == "CXXRecordDecl"), {})
if not record.get("completeDefinition"):
return
lines.add(f"{scope}{name} {record.get('tagUsed', 'class')} {member_access}")
section = "public" if record.get("tagUsed") == "struct" else "private"
for child in record.get("inner", []):
if child.get("kind") == "AccessSpecDecl":
section = child.get("access", section)
else:
walk(child, f"{scope}{name}::", section, lines)
elif kind == "EnumDecl" and name:
lines.add(f"{scope}{name} enum {member_access}")
for constant in node.get("inner", []):
if constant.get("kind") == "EnumConstantDecl":
lines.add(f"{scope}{name}::{constant['name']} enumerator {member_access}")
elif kind in MEMBERS and name:
lines.add(f"{scope}{name} {MEMBERS[kind]} {signature(node)} {member_access}")


def main():
"""Reads the JSON AST of include/countly.hpp, filtered to "cly", from stdin and prints the public API lines."""
# A set, because a declaration can arrive in more than one document.
lines = set()
for document in documents(sys.stdin.read()):
if document.get("kind") == "NamespaceDecl" and document.get("name") == "cly":
walk(document, "", "public", lines)
if not lines:
sys.exit("No declarations in namespace cly were found in the AST.")
print("\n".join(sorted(lines)))


if __name__ == "__main__":
main()
37 changes: 37 additions & 0 deletions .github/scripts/public_api_record.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
#!/usr/bin/env bash
# Compares the public API this run extracted with the record committed in the repository. On a difference it lists the
# changed lines in the job summary, copies the extracted API to $RUNNER_TEMP/public-api-record/ under the record's path,
# for the workflow to upload, and fails: a deliberate change is committed together with a changelog line.
# $@ pairs "<extracted file>=<record path>"
set -euo pipefail
out="$RUNNER_TEMP/public-api-record"
status=0
current="$RUNNER_TEMP/public-api-current.txt"
recorded="$RUNNER_TEMP/public-api-recorded.txt"
for pair in "$@"; do
extracted=${pair%%=*}
record=${pair#*=}
# Line endings do not count: .NET writes CRLF on Windows, and a Windows checkout can give the record CRLF too.
tr -d '\r' < "$extracted" > "$current"
if [ -f "$record" ] && tr -d '\r' < "$record" | cmp -s - "$current"; then
echo "The public API is the one $record records."
continue
fi
status=1
mkdir -p "$out/$(dirname "$record")"
cp "$current" "$out/$record"
{
printf '### The public API differs from %s\n\n' "$record"
printf "Commit \`%s\` from the \`public-api-record\` artifact of this run, with a changelog line for the change.\n\n" "$record"
if [ -f "$record" ]; then
printf '```diff\n'
tr -d '\r' < "$record" > "$recorded"
diff -u "$recorded" "$current" | tail -n +3 | head -n 300 || true
printf '```\n\n'
else
printf 'There is no record yet.\n\n'
fi
} >> "$GITHUB_STEP_SUMMARY"
echo "::error title=Public API record::The public API differs from $record. Commit the file from this run's public-api-record artifact and add a changelog line."
done
exit "$status"
46 changes: 46 additions & 0 deletions .github/scripts/public_api_report.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
#!/usr/bin/env bash
# Writes the report of a pull request's public API changes to the job summary, and to the step output "report", which
# the workflow's comment job posts on the pull request. The comment lists at most 200 lines per section.
# $1 a file with one removed or changed declaration per line
# $2 a file with one declaration per line that the pull request adds to the public API
set -euo pipefail
breaking=$1
added=$2
marker='<!-- countly-public-api -->'

# Prints a section titled $1 and explained by $2 with the lines of file $3, at most $4 of them when $4 is not 0.
section() {
local count
count=$(grep -c . "$3" || true)
[ "$count" -gt 0 ] || return 0
printf '**%s: %s.** %s\n\n```\n' "$1" "$count" "$2"
if [ "$4" -gt 0 ]; then
head -n "$4" "$3"
else
cat "$3"
fi
printf '```\n'
if [ "$4" -gt 0 ] && [ "$count" -gt "$4" ]; then
printf '\n%s more in the job summary.\n' "$((count - $4))"
fi
printf '\n'
}

# Prints the whole report, with at most $1 lines per section when $1 is not 0.
report() {
printf '%s\n### Public API changes\n\n' "$marker"
if ! grep -q . "$breaking" && ! grep -q . "$added"; then
printf 'No public API change.\n'
return
fi
section 'Removed or changed' 'Each needs a CHANGELOG entry that marks it as a breaking change.' "$breaking" "$1"
section 'Added' 'Check that each one is meant to be public.' "$added" "$1"
}

report 0 >> "$GITHUB_STEP_SUMMARY"
delimiter="EOF_$(od -An -N8 -tx1 /dev/urandom | tr -d ' \n')"
{
echo "report<<$delimiter"
report 200
echo "$delimiter"
} >> "$GITHUB_OUTPUT"
52 changes: 52 additions & 0 deletions .github/workflows/api-changelog.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
name: Public API changelog

# A pull request that changes or removes a record of the public API must also change the CHANGELOG.md that API_FILES
# pairs it with, so that the release notes tell integrators about every such change. A record the pull request adds needs
# no line. A path ending in "/" stands for every file under it. The API check keeps each record in step with the SDK.
on:
pull_request:
branches: [master, staging]

concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true

permissions:
pull-requests: read # to list the files the pull request changes

jobs:
changelog:
name: A public API change comes with a CHANGELOG line
runs-on: ubuntu-24.04
steps:
- name: Check the files the pull request changes
env:
GH_TOKEN: ${{ github.token }}
REPO: ${{ github.repository }}
PR: ${{ github.event.pull_request.number }}
API_FILES: api/countly-sdk-cpp.api=CHANGELOG.md
run: |
changed=$(gh api "repos/$REPO/pulls/$PR/files" --paginate --jq '.[] | select(.status != "added") | .filename')
changes() {
case "$1" in
*/) printf '%s\n' "$changed" | awk -v prefix="$1" 'index($0, prefix) == 1 { found = 1 } END { exit !found }' ;;
*) printf '%s\n' "$changed" | grep -qxF "$1" ;;
esac
}
recorded_changed=no
for pair in $API_FILES; do
record=${pair%%=*}
changelog=${pair#*=}
if changes "$record"; then
recorded_changed=yes
if ! changes "$changelog"; then
echo "::error::This pull request changes $record, which records the public API, but not $changelog. Add a line that tells integrators what changed."
exit 1
fi
fi
done
if [ "$recorded_changed" = yes ]; then
echo "The recorded public API changes, and the CHANGELOG changes with it."
else
echo "This pull request changes no recorded public API."
fi
100 changes: 100 additions & 0 deletions .github/workflows/public_api.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
name: Public API changes

on:
pull_request:

permissions:
contents: read

concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true

jobs:
api:
name: Breaking changes
outputs:
report: ${{ steps.report.outputs.report }}
runs-on: ubuntu-24.04
steps:
- name: Checkout SDK code
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
with:
fetch-depth: 2
submodules: "recursive"
persist-credentials: false

# The first parent of the pull request's merge commit is the base branch it merges into.
# Both sides read the vendored JSON headers of this pull request: a worktree has no submodules checked out.
- name: Compare the public API with the base branch
id: compare
run: |
git worktree add "$RUNNER_TEMP/base" HEAD^1
for side in base head; do
tree="$GITHUB_WORKSPACE"
if [ "$side" = base ]; then
tree="$RUNNER_TEMP/base"
fi
clang++-18 -x c++ -std=c++14 -fsyntax-only -I"$tree/include" -I"$GITHUB_WORKSPACE/vendor/json/include" \
-Xclang -ast-dump=json -Xclang -ast-dump-filter=cly "$tree/include/countly.hpp" \
| python3 .github/scripts/public_api.py > "$RUNNER_TEMP/$side.txt"
done
LC_ALL=C comm -23 "$RUNNER_TEMP/base.txt" "$RUNNER_TEMP/head.txt" | sed 's/^/Removed or changed: /' > "$RUNNER_TEMP/breaking.txt"
LC_ALL=C comm -13 "$RUNNER_TEMP/base.txt" "$RUNNER_TEMP/head.txt" > "$RUNNER_TEMP/added.txt"

- name: Report the public API changes
id: report
run: bash .github/scripts/public_api_report.sh "$RUNNER_TEMP/breaking.txt" "$RUNNER_TEMP/added.txt"

- name: Require a CHANGELOG entry for breaking changes
run: |
if [ ! -s "$RUNNER_TEMP/breaking.txt" ]; then
exit 0
fi
if git diff --quiet HEAD^1 HEAD -- CHANGELOG.md; then
echo "::error title=Breaking API change::This pull request breaks the public API without a CHANGELOG.md entry. The job summary lists each break."
exit 1
fi
echo "::warning title=Breaking API change::This pull request breaks the public API. Check that CHANGELOG.md marks it as a breaking change."

# The committed record keeps every change to the public API visible in the pull request's diff.
- name: Compare the public API with its record
id: record
if: ${{ !cancelled() && steps.compare.outcome == 'success' }}
run: >
bash .github/scripts/public_api_record.sh
"$RUNNER_TEMP/head.txt=api/countly-sdk-cpp.api"

- name: Upload the new record
if: ${{ !cancelled() && steps.record.outcome == 'failure' }}
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: public-api-record
path: ${{ runner.temp }}/public-api-record

comment:
name: Comment the public API changes
needs: api
# A fork's or Dependabot's pull request only gets a read-only token, so its report stays in the job summary.
if: >-
always() && needs.api.outputs.report != '' &&
github.event.pull_request.head.repo.full_name == github.repository &&
github.event.pull_request.user.login != 'dependabot[bot]'
runs-on: ubuntu-24.04
permissions:
pull-requests: write
steps:
- name: Post or update the comment
env:
GH_TOKEN: ${{ github.token }}
REPO: ${{ github.repository }}
PR: ${{ github.event.pull_request.number }}
REPORT: ${{ needs.api.outputs.report }}
run: |
id=$(gh api --paginate "repos/$REPO/issues/$PR/comments" --jq \
'.[] | select(.user.login == "github-actions[bot]" and (.body | startswith("<!-- countly-public-api -->"))) | .id' | tail -n 1)
if [ -n "$id" ]; then
gh api --method PATCH "repos/$REPO/issues/comments/$id" -f "body=$REPORT" > /dev/null
elif ! grep -qx 'No public API change.' <<< "$REPORT"; then
gh api --method POST "repos/$REPO/issues/$PR/comments" -f "body=$REPORT" > /dev/null
fi
Loading
Loading