diff --git a/.github/scripts/dep_table.py b/.github/scripts/dep_table.py new file mode 100644 index 000000000..0073be3ca --- /dev/null +++ b/.github/scripts/dep_table.py @@ -0,0 +1,131 @@ +#!/usr/bin/env python3 +"""Print a markdown table of Boost dependency changes between two CI log archives. + +Usage: dep_table.py BASELINE.zip AFTER.zip + +Each archive is a GitHub Actions run-log zip. The `deps` job of the +dependency-report workflow prints two marker-delimited boostdep reports into +its log: + + ===DEP-BRIEF-START=== boostdep --brief graph ===DEP-BRIEF-END=== + ===DEP-PRIMARY-START=== boostdep graph (primary) ===DEP-PRIMARY-END=== + +From those it derives two metrics and prints their delta vs the baseline: + - the set of transitive Boost modules graph depends on (from --brief: + Primary + Secondary sections = the full transitive closure), and + - the header-inclusion weight of each direct dependency (from the primary + report: how many distinct boost/graph files pull that dependency in). +Weights are the live signal, they drop toward zero as coupling is removed; +the module count is the coarse target that only moves when a dep hits zero. +""" +import re +import sys +import zipfile + +BRIEF = ("===DEP-BRIEF-START===", "===DEP-BRIEF-END===") +PRIMARY = ("===DEP-PRIMARY-START===", "===DEP-PRIMARY-END===") +# Module names as boostdep prints them, e.g. "numeric~conversion". +MODULE = re.compile(r"[A-Za-z0-9_.~-]+") +TIMESTAMP = re.compile(r"^\d{4}-\d\d-\d\dT[\d:.]+Z\s?") # GitHub log line prefix +ANSI = re.compile(r"\x1b\[[0-9;]*m") + + +def read_clean_lines(zip_path): + """Top-level per-job logs, with GitHub timestamp and ANSI prefixes stripped.""" + lines = [] + with zipfile.ZipFile(zip_path) as z: + for entry in z.namelist(): + if "/" in entry or not entry.endswith(".txt"): + continue + for ln in z.read(entry).decode("utf-8", "replace").splitlines(): + lines.append(TIMESTAMP.sub("", ANSI.sub("", ln))) + return lines + + +def block(lines, markers): + """Lines strictly between the marker lines, matched exactly. + + Exact matching is what skips the echoed `echo ""` command lines + GitHub prepends to a run step, so we capture boostdep's real stdout. + """ + start, end = markers + out, capturing = [], False + for ln in lines: + s = ln.strip() + if s == start: + capturing = True + continue + if s == end and capturing: + break + if capturing: + out.append(ln) + return out + + +def parse_brief(lines): + """--brief output -> set of module names (one bare name per line).""" + mods = set() + for ln in lines: + s = ln.strip() + if not s or s.startswith("#") or s.lower().startswith("brief dependency"): + continue + if MODULE.fullmatch(s): + mods.add(s) + return mods + + +def parse_weights(lines): + """Primary report -> {dependency: number of distinct graph files that pull it in}.""" + weights, cur = {}, None + for ln in lines: + head = re.match(r"^([A-Za-z0-9_.~-]+):\s*$", ln) # "module:" at column 0 + if head: + cur = head.group(1) + weights.setdefault(cur, set()) + continue + frm = re.match(r"^\s+from\s+(.+?)\s*$", ln) + if frm and cur is not None: + weights[cur].add(frm.group(1)) + return {k: len(v) for k, v in weights.items()} + + +def main(base_zip, pr_zip): + bl, pl = read_clean_lines(base_zip), read_clean_lines(pr_zip) + base_mods = parse_brief(block(bl, BRIEF)) + pr_mods = parse_brief(block(pl, BRIEF)) + base_w = parse_weights(block(bl, PRIMARY)) + pr_w = parse_weights(block(pl, PRIMARY)) + + # Header-inclusion weights: the live signal. Only rows that changed, most-reduced first. + print("**Header-inclusion weights** (graph files pulling each direct dependency in):") + print() + changed = [] + for dep in base_w.keys() | pr_w.keys(): + b, p = base_w.get(dep, 0), pr_w.get(dep, 0) + if b != p: + changed.append((p - b, dep, b, p)) + if changed: + print("| Dependency | develop | PR | Δ |") + print("|-----|--------:|---:|----:|") + for d, dep, b, p in sorted(changed): # reductions (negative delta) first + print(f"| {dep} | {b} | {p} | {d:+d} |") + else: + print("_No header-inclusion-weight changes._") + print() + + # Transitive module set: the coarse target. + added = sorted(pr_mods - base_mods) + removed = sorted(base_mods - pr_mods) + nb, np_ = len(base_mods), len(pr_mods) + diff = np_ - nb + print(f"**Transitive Boost modules:** {nb} → {np_} ({f'{diff:+d}' if diff else '0'})") + if added: + print(f"- added: {', '.join(added)}") + if removed: + print(f"- removed: {', '.join(removed)}") + + +if __name__ == "__main__": + if len(sys.argv) != 3: + sys.exit("usage: dep_table.py BASELINE.zip AFTER.zip") + main(sys.argv[1], sys.argv[2]) diff --git a/.github/workflows/dependency-count-comment.yml b/.github/workflows/dependency-count-comment.yml new file mode 100644 index 000000000..ddf49b0a1 --- /dev/null +++ b/.github/workflows/dependency-count-comment.yml @@ -0,0 +1,61 @@ +name: dependency-count-comment + +on: + workflow_run: + workflows: ["dependency-report"] + types: [completed] + +permissions: + actions: read + pull-requests: write + contents: read + +jobs: + comment: + if: github.event.workflow_run.event == 'pull_request' + runs-on: ubuntu-latest + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + REPO: ${{ github.repository }} + PR_RUN: ${{ github.event.workflow_run.id }} + HEAD_SHA: ${{ github.event.workflow_run.head_sha }} + steps: + - uses: actions/checkout@v4 + + - name: Download this PR run's log archive + run: gh api "repos/$REPO/actions/runs/$PR_RUN/logs" > pr-logs.zip + + - name: Download latest successful develop run's log archive + run: | + DEV=$(gh run list --workflow dependency-report --branch develop --status success \ + --limit 1 --json databaseId --jq '.[0].databaseId') + if [ -z "$DEV" ]; then echo "no develop baseline run found" >&2; exit 1; fi + echo "DEV_RUN=$DEV" >> "$GITHUB_ENV" + gh api "repos/$REPO/actions/runs/$DEV/logs" > base-logs.zip + + - name: Generate table + run: | + { + echo "Boost dependency footprint vs \`develop\` (auto-generated)." + echo "PR run \`$PR_RUN\` vs develop run \`$DEV_RUN\` (\`${HEAD_SHA:0:10}\`)." + echo + python3 .github/scripts/dep_table.py base-logs.zip pr-logs.zip + } > table.md + + - name: Resolve PR number + id: pr + run: | + num=$(gh api "repos/$REPO/pulls?state=open&per_page=100" \ + --jq "map(select(.head.sha==\"$HEAD_SHA\"))[0].number") + if [ -z "$num" ]; then + echo "no open PR found with head $HEAD_SHA" >&2 + exit 1 + fi + echo "num=$num" >> "$GITHUB_OUTPUT" + + - name: Post or update sticky comment + uses: marocchino/sticky-pull-request-comment@v2 + with: + header: dependency-counts + number: ${{ steps.pr.outputs.num }} + path: table.md diff --git a/.github/workflows/dependency-report.yml b/.github/workflows/dependency-report.yml new file mode 100644 index 000000000..c788d5e06 --- /dev/null +++ b/.github/workflows/dependency-report.yml @@ -0,0 +1,42 @@ +name: dependency-report + +# Computes graph's Boost dependency footprint with boostdep and prints a +# marker-delimited report into the job log, on every push and PR. The +# dependency-count-comment workflow reads these logs (this PR's run and the +# latest develop run) and posts the diff as a PR comment. Kept separate from +# CI so ci.yml is untouched. + +on: [ push, pull_request ] + +jobs: + deps: + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@v4 + - name: Set up Boost tree and build boostdep + run: | + git clone -b develop --depth 1 https://github.com/boostorg/boost.git ../boost-root + cd ../boost-root + cp -r $GITHUB_WORKSPACE/* libs/graph + git submodule update --init tools/boostdep + # depinst pulls graph's deps; also ensure boostdep's own dep + # (Boost.Filesystem) is present since boostdep links against it. + python tools/boostdep/depinst/depinst.py --git_args "--jobs 3" graph + python tools/boostdep/depinst/depinst.py --git_args "--jobs 3" filesystem + ./bootstrap.sh + ./b2 headers + # boostdep #includes and links the compiled + # library, so build it with b2 (a bare c++ compile can't resolve that). + ./b2 tools/boostdep/build + - name: Boostdep dependency report + working-directory: ../boost-root + run: | + BOOSTDEP=$(find "$PWD" -type f -name boostdep -perm -u+x 2>/dev/null | grep -v '/src/' | head -1) + if [ -z "$BOOSTDEP" ]; then echo "boostdep executable not found after build" >&2; exit 1; fi + echo "using boostdep: $BOOSTDEP" + echo "===DEP-BRIEF-START===" + "$BOOSTDEP" --boost-root . --track-sources --no-track-tests --brief graph + echo "===DEP-BRIEF-END===" + echo "===DEP-PRIMARY-START===" + "$BOOSTDEP" --boost-root . --track-sources --no-track-tests graph + echo "===DEP-PRIMARY-END==="