From 61b42007e27ffe77683ab1536e44bf72d8c187ea Mon Sep 17 00:00:00 2001 From: macanderson Date: Fri, 31 Jul 2026 21:09:55 -0700 Subject: [PATCH] docs+ci(changelog): AI-draft entries for merges that skip the changelog, and backfill the gaps MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CHANGELOG.md relied on every PR remembering to write its own [Unreleased] entry. An audit of the 22 merges since v0.1.0 whose numbers never appear in the file found 9 user-visible changes with no entry at all — including the OCP -> CGP rename itself, the repo's biggest breaking change. The Unreleased section had also accumulated duplicate category headings (two '### Added', two '### Changed', two '### Fixed') from PRs appending their own blocks. Go-forward: .github/workflows/changelog.yml watches pushes to main. When a merge lands without touching CHANGELOG.md, it drafts the missing entries from the merge's actual diff (.github/scripts/changelog-ai.sh, Vercel AI Gateway, degrade-open: no key or a failed call is a log line, never a red check) and proposes them as a bot/changelog PR — a draft for human review, never a direct push. The gap is measured from the last commit that touched CHANGELOG.md, so merging the bot PR resets it, and each run regenerates the whole current gap, so a superseded bot PR loses nothing. Backfill: entries for the rename (#1), frame identity/composition/usage reports (#32), golden wire fixtures (#35), ADR 0007 (#61, #27), composition conformance + the canary scheduled-run gate (#70) — written from each PR's diff, in the file's existing voice. Three further audit hits (#68, #69, #71) turned out to be already covered by newer entries and were left alone. Duplicate category headings merged: one heading per category, Keep-a-Changelog order. --- .github/scripts/changelog-ai.sh | 75 ++++++++++++++++++++ .github/workflows/changelog.yml | 118 +++++++++++++++++++++++++++++++ CHANGELOG.md | 119 +++++++++++++++++++++++--------- 3 files changed, 278 insertions(+), 34 deletions(-) create mode 100755 .github/scripts/changelog-ai.sh create mode 100644 .github/workflows/changelog.yml diff --git a/.github/scripts/changelog-ai.sh b/.github/scripts/changelog-ai.sh new file mode 100755 index 0000000..2a4fdea --- /dev/null +++ b/.github/scripts/changelog-ai.sh @@ -0,0 +1,75 @@ +#!/usr/bin/env bash +# +# Draft CHANGELOG.md entries for a range of merges from their actual diff. +# +# Usage: changelog-ai.sh e.g. changelog-ai.sh abc123..HEAD +# +# Prints Keep-a-Changelog bullets (grouped under ### Added / ### Changed / +# ### Fixed) for the user-visible changes in the range, in this repo's +# changelog voice, to stdout. Prints NOTHING and exits 0 when it cannot or +# should not draft — no API key, the call failed, the range holds nothing +# user-facing, or the response didn't look like changelog markdown — so the +# caller can treat empty output as "leave the changelog alone". +# +# Called from .github/workflows/changelog.yml, which runs it over the merges +# that landed since the last commit that touched CHANGELOG.md and proposes +# the result as a bot PR — a draft for human review, never a direct push. +# +# AI_GATEWAY_API_KEY required to produce output; unset -> warn, print nothing +# CHANGELOG_AI_MODEL optional, default anthropic/claude-sonnet-5 +set -euo pipefail + +range="${1:?usage: changelog-ai.sh }" + +if ! command -v jq >/dev/null 2>&1; then + echo "changelog-ai: jq not found; printing nothing." >&2 + exit 0 +fi +if [ -z "${AI_GATEWAY_API_KEY:-}" ]; then + echo "changelog-ai: AI_GATEWAY_API_KEY not set; printing nothing." >&2 + exit 0 +fi + +model="${CHANGELOG_AI_MODEL:-anthropic/claude-sonnet-5}" + +commits="$(git log --no-merges --format='%s' "$range" | head -n 100 || true)" +if [ -z "$commits" ]; then + echo "changelog-ai: no commits in range '${range}'; printing nothing." >&2 + exit 0 +fi + +bodies="$(git log --no-merges --format='--- %s%n%b' "$range" | head -n 400 || true)" +stat="$(git diff --stat "$range" -- ':(exclude)Cargo.lock' ':(exclude)site/pnpm-lock.yaml' | tail -n 100 || true)" +diff="$(git diff "$range" -- ':(exclude)Cargo.lock' ':(exclude)site/pnpm-lock.yaml' 2>/dev/null | head -c 100000 || true)" + +# shellcheck disable=SC2016 # the format string's backticks are literal markdown +prompt="$(printf '%s\n\n## Commit subjects\n%s\n\n## Commit bodies (squash-merge PR descriptions)\n%s\n\n## Files changed\n%s\n\n## Diff (truncated)\n```diff\n%s\n```\n' \ + 'Write CHANGELOG.md bullets for the merges below, from the Context Graph Protocol repository: a protocol specification plus Rust crates (contextgraph-types, contextgraph-host, contextgraph-conformance), TypeScript/Python/Go SDKs, and a docs site. Keep a Changelog format: "### Added" / "### Changed" / "### Fixed" / "### Removed" headings (only the ones that apply, in that order), one bullet per distinct change, in this repository'"'"'s established voice: a bold lead phrase, then the PR reference in parens, then an em-dash and two to four lines of concrete prose wrapped at 80 columns — for example: "- **Frame representations — full/compact/reference** (#41) — a provider can now answer with ...". Cover only what a protocol implementer, SDK user, or spec reader would notice: wire types, host runtime behavior, conformance checks, CLI, SDKs, published schemas, normative spec text, docs pages. Skip repo chores, lockfiles, logos, CI plumbing, and site build internals entirely. Base every claim on the diff below — commit messages describe intent, the diff is what shipped. If NOTHING in the range is user-visible, output exactly the single word NOTHING. Output ONLY the bullet markdown - no version heading, no preamble, no code fences.' \ + "$commits" "$bodies" "$stat" "$diff")" + +payload="$(jq -n --arg m "$model" --arg c "$prompt" \ + '{model:$m, messages:[{role:"user",content:$c}], temperature:0.2}')" + +resp="$(curl -sS --max-time 150 \ + -H "Authorization: Bearer ${AI_GATEWAY_API_KEY}" \ + -H "Content-Type: application/json" \ + -d "$payload" \ + https://ai-gateway.vercel.sh/v1/chat/completions || true)" + +entries="$(printf '%s' "$resp" | jq -r '.choices[0].message.content // empty' 2>/dev/null || true)" + +entries="$(printf '%s\n' "$entries" | sed -e '/^```/d')" +entries="$(printf '%s' "$entries" | sed -e 's/[[:space:]]*$//')" +if [ "$entries" = "NOTHING" ]; then + echo "changelog-ai: model judged the range internal-only; printing nothing." >&2 + exit 0 +fi +case "$entries" in + '### '* | '- '*) ;; + *) + echo "changelog-ai: response did not look like changelog markdown; printing nothing." >&2 + exit 0 + ;; +esac + +printf '%s\n' "$entries" diff --git a/.github/workflows/changelog.yml b/.github/workflows/changelog.yml new file mode 100644 index 0000000..70c9919 --- /dev/null +++ b/.github/workflows/changelog.yml @@ -0,0 +1,118 @@ +name: changelog + +# Keep CHANGELOG.md's [Unreleased] section from silently falling behind main. +# +# The convention is that a PR records its own user-visible changes under +# `## [Unreleased]` in the same merge. When a merge lands WITHOUT touching +# CHANGELOG.md, this workflow drafts the missing entries from the actual +# diff (.github/scripts/changelog-ai.sh) and proposes them as a bot PR — +# a draft for human review, never a direct push to main. +# +# The gap is self-resetting: it is measured from the last commit that +# touched CHANGELOG.md to HEAD. Merging the bot PR touches CHANGELOG.md, +# which empties the gap; each run regenerates the WHOLE current gap, so +# force-updating the bot branch never loses coverage, and a bot PR left +# unmerged while more merges land is simply superseded by a fuller one. +# +# Degrade-open: no AI_GATEWAY_API_KEY, a failed call, or an internal-only +# gap produces no PR and a log line — never a red check. + +on: + push: + branches: [main] + workflow_dispatch: + +concurrency: + group: changelog + cancel-in-progress: false + +permissions: + contents: write # push the bot/changelog branch + pull-requests: write # open/update the bot PR + +jobs: + draft: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + with: + fetch-depth: 0 + + - name: Measure the gap since the changelog was last touched + id: gap + run: | + set -euo pipefail + base="$(git log -1 --format=%H HEAD -- CHANGELOG.md)" + if [ -z "$base" ]; then + echo "no commit has ever touched CHANGELOG.md; nothing to measure." + echo "empty=true" >> "$GITHUB_OUTPUT" + exit 0 + fi + count="$(git rev-list --count "${base}..HEAD")" + echo "gap: ${count} commit(s) since ${base} last touched CHANGELOG.md" + if [ "$count" -eq 0 ]; then + echo "empty=true" >> "$GITHUB_OUTPUT" + else + echo "empty=false" >> "$GITHUB_OUTPUT" + echo "base=${base}" >> "$GITHUB_OUTPUT" + fi + + - name: Draft entries from the gap's diff + id: draft + if: steps.gap.outputs.empty != 'true' + env: + AI_GATEWAY_API_KEY: ${{ secrets.AI_GATEWAY_API_KEY }} + BASE: ${{ steps.gap.outputs.base }} + run: | + set -euo pipefail + entries="${RUNNER_TEMP}/changelog-entries.md" + ./.github/scripts/changelog-ai.sh "${BASE}..HEAD" > "${entries}" || true + if [ -s "${entries}" ]; then + echo "entries=${entries}" >> "$GITHUB_OUTPUT" + echo "----- drafted entries -----"; cat "${entries}" + else + echo "nothing drafted (no key, call failed, or internal-only gap); done." + fi + + - name: Propose the entries as a bot PR + if: steps.gap.outputs.empty != 'true' && steps.draft.outputs.entries != '' + env: + GH_TOKEN: ${{ github.token }} + BASE: ${{ steps.gap.outputs.base }} + ENTRIES_FILE: ${{ steps.draft.outputs.entries }} + run: | + set -euo pipefail + branch="bot/changelog" + + git checkout -B "${branch}" HEAD + + # Insert the drafted bullets directly under `## [Unreleased]`, + # above whatever the section already holds — the gap's commits by + # definition wrote none of the existing entries, so nothing can be + # duplicated. + perl -pi -e ' + if (/^## \[Unreleased\]$/) { + open my $fh, "<", $ENV{ENTRIES_FILE} or next; + my $e = do { local $/; <$fh> }; + $e =~ s/\s+\z//; + $_ .= "\n" . $e . "\n"; + }' CHANGELOG.md + if git diff --quiet -- CHANGELOG.md; then + echo "insertion was a no-op ([Unreleased] heading missing?); done." + exit 0 + fi + + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git add CHANGELOG.md + git commit -m "docs(changelog): record the merges since ${BASE:0:8} touched the changelog" + git push -f origin "HEAD:refs/heads/${branch}" + + title="docs(changelog): AI-drafted entries for merges that skipped the changelog" + body="$(printf 'One or more merges landed without touching CHANGELOG.md. These entries were drafted from the actual diff of %s..main by .github/scripts/changelog-ai.sh.\n\nReview like any contribution: edit freely, or close if the gap really was internal-only. Each push to main regenerates this PR to cover the whole current gap, so it is always the newest, fullest draft.' "${BASE:0:8}")" + existing="$(gh pr list --head "${branch}" --state open --json number --jq '.[0].number // empty')" + if [ -n "${existing}" ]; then + gh pr edit "${existing}" --title "${title}" --body "${body}" + else + gh pr create --base main --head "${branch}" --title "${title}" --body "${body}" + fi diff --git a/CHANGELOG.md b/CHANGELOG.md index b07fee0..c5df01d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,9 +9,17 @@ independent axes — see [docs/stability.md](./docs/stability.md). This changelo records crate releases and spec-repository milestones together, noting which is which. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). +A PR records its own user-visible changes under `## [Unreleased]` in the same +merge. When a merge skips that, [`changelog.yml`](.github/workflows/changelog.yml) +drafts the missing entries from the merge's actual diff +([`changelog-ai.sh`](.github/scripts/changelog-ai.sh)) and proposes them as a +bot PR for review — the file never falls silently behind main, and no drafted +text lands without a human merge. + ## [Unreleased] ### Added + - **First real crates.io publish** (2026-07-31) — `contextgraph-types`, `contextgraph-host`, and `contextgraph-conformance` 0.1.0 are live, published manually in dependency order per [PUBLISHING.md](./PUBLISHING.md) (one-shot @@ -229,7 +237,83 @@ which. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1 - Recommended relation vocabulary `frame::rel` (#7). - Embedding fingerprint format and exact-match rule (E1) (#11). +- [`schema/contextgraph-envelope.schema.json`](./schema/contextgraph-envelope.schema.json) — a + machine-readable JSON Schema (Draft 2020-12) for the Context Graph Protocol envelope and all wire + types. Validates in any language (`ajv`, Python `jsonschema`, Rust + `jsonschema`, Go `gojsonschema`). Includes `schema/validate-examples.py` to + check the bundled examples and serve as a validator-usage reference. +- [`examples/`](./examples/) — diffable wire transcripts of a complete Context Graph Protocol + session (NDJSON + pretty-printed reference messages), so an implementer in + any language can diff their output against the exact shapes on the wire. +- `GOVERNANCE.md` — maintainer-led model, normative-change process, and the + concrete criteria for the `contextgraph/1.0-draft` → `contextgraph/1.0` freeze. +- Repository governance files: `SECURITY.md`, `CODE_OF_CONDUCT.md`, and + GitHub issue/PR templates. +- Prominent **License** section in the README clarifying the dual MIT OR + Apache-2.0 licensing of all Context Graph Protocol crates. +- A consolidated **Conformance requirements** section in + `docs/protocol-surface.md`, with RFC 2119 keywords and a formal ABNF grammar + for the protocol version string. + +- **Frame identity, deterministic composition, and usage reports** (#32) — + `FrameId`, the (provider, frame, content-digest) triple whose derived order + IS the canonical composition order; `compose_context` renders an unchanged + frame set byte-identically across turns and hosts, so prompt-cache prefixes + stay stable by construction; `ContextFrame.content_digest` joins the type and + schema; and `UsageReport` itemizes a fan-out's per-frame cost so a billed + total re-sums from the exact frames it names (`FanOut::usage_report`). +- **Golden wire fixtures with attested digests** (#35) — a published vector set + under `contextgraph-conformance` (frame and query fixtures, digest-profile + vectors, a manifest of JCS-canonicalized sha256 hashes) that a downstream + implementation copies and drift-gates against, so "our wire matches the + reference" is a diff, not a claim. +- **ADR 0007 — the protocol/product boundary** (#61, #27) — classifies every + delta from the adaptive-context bundle against the spec: the bundle's + task-wide frame is the host-owned `CompiledContextFrame`, not the protocol's + atomic `ContextFrame`; frames stay evidence, never directives; and the CGEP + rename is rejected — the canonical name remains Context Graph Protocol. + `docs/adaptive-context-reconciliation.md` routes each adopted item to a spec + change or a normative-track issue. +- **Composition conformance — a downstream host can now be certified** (#70) — + `run_host_conformance` only ever certified this repo's reference host, and + three individually honest providers can still jointly exceed a query's + budget once composed. A new `composition_conformance` suite drives any + `ComposingHost` through four checks: the cross-provider token bound, the + total partition (every offered frame admitted or reported dropped, never + silently truncated), the audit quarantine, and deterministic render order. + Also exports `refuse_insecure_transport`, so C7's loopback rule has one + implementation rather than one per host. + +### Changed + +- **Breaking:** `token_cost` MUST now equal the canonical count for its content. + Providers that under-declared cost were previously green (#8). +- Withdrew the incorrect claim that CGP rides JSON-RPC 2.0 (#4). +- Code comments cite `SPEC.md` anchors instead of a private repository (#3). + +- `docs/protocol-advantages.md`: corrected "MIT licensed" to the accurate + dual-license statement ("MIT OR Apache-2.0") to match the rest of the repo. +- `docs/protocol-advantages.md`: fixed a misspelling — "BTreive" → "Btrieve". +- `docs/protocol-advantages.md`, `docs/running-conformance.md`: removed leftover + references to the unrelated `stella` project, replacing them with Context Graph Protocol-specific + names (`contextgraph-graph`, `contextgraph-example-docs`). + +- **Breaking:** the project is renamed — Open Context Protocol (OCP) → + Context Graph Protocol (#1). Crates `ocp-types`/`ocp-host`/`ocp-conformance` + become `contextgraph-*`, binaries `ocp-inspect`/`ocp-example-docs` become + `contextgraph-*`, the wire protocol version `ocp/1.0-draft` becomes + `contextgraph/1.0-draft`, and the schema file and `$id` move with them. + `MIGRATION.md` carries the rename map and the redirect hazard for + downstreams pinning the old URLs. +- **The downstream canary's scheduled run now fails on a break** (#70) — the + canary caught a real downstream break on 2026-07-29, emitted its warning, + and reported success; nobody saw it. A scheduled run gates no PR, so failing + costs nothing and buys a red run plus GitHub's failure notification. The + `pull_request` path stays advisory — a downstream repo still cannot block a + merge here. + ### Removed + - **The `site/` documentation app is retired** (#57, [ADR 0008](./docs/adr/0008-deploy-topology-and-advertised-urls.md)). It was a fumadocs/Next app built on every PR as a hard gate and deployed nowhere, @@ -249,6 +333,7 @@ which. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1 host. Wire-compatible; Rust API breaking (#5, #6, #11). ### Fixed + - **The three SDK example providers now serve verifiable file provenance.** The Python, TypeScript, and Go `example-docs` fixtures cited `file:///docs/…` paths that exist on no machine, with placeholder digests @@ -325,40 +410,6 @@ which. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1 validate serialized frames (not just hand-authored examples) against the schema. `content` remains governed per-representation by the existing `allOf`. -### Changed -- **Breaking:** `token_cost` MUST now equal the canonical count for its content. - Providers that under-declared cost were previously green (#8). -- Withdrew the incorrect claim that CGP rides JSON-RPC 2.0 (#4). -- Code comments cite `SPEC.md` anchors instead of a private repository (#3). - -### Added -- [`schema/contextgraph-envelope.schema.json`](./schema/contextgraph-envelope.schema.json) — a - machine-readable JSON Schema (Draft 2020-12) for the Context Graph Protocol envelope and all wire - types. Validates in any language (`ajv`, Python `jsonschema`, Rust - `jsonschema`, Go `gojsonschema`). Includes `schema/validate-examples.py` to - check the bundled examples and serve as a validator-usage reference. -- [`examples/`](./examples/) — diffable wire transcripts of a complete Context Graph Protocol - session (NDJSON + pretty-printed reference messages), so an implementer in - any language can diff their output against the exact shapes on the wire. -- `GOVERNANCE.md` — maintainer-led model, normative-change process, and the - concrete criteria for the `contextgraph/1.0-draft` → `contextgraph/1.0` freeze. -- Repository governance files: `SECURITY.md`, `CODE_OF_CONDUCT.md`, and - GitHub issue/PR templates. -- Prominent **License** section in the README clarifying the dual MIT OR - Apache-2.0 licensing of all Context Graph Protocol crates. -- A consolidated **Conformance requirements** section in - `docs/protocol-surface.md`, with RFC 2119 keywords and a formal ABNF grammar - for the protocol version string. - -### Changed -- `docs/protocol-advantages.md`: corrected "MIT licensed" to the accurate - dual-license statement ("MIT OR Apache-2.0") to match the rest of the repo. -- `docs/protocol-advantages.md`: fixed a misspelling — "BTreive" → "Btrieve". -- `docs/protocol-advantages.md`, `docs/running-conformance.md`: removed leftover - references to the unrelated `stella` project, replacing them with Context Graph Protocol-specific - names (`contextgraph-graph`, `contextgraph-example-docs`). - -### Fixed - `contextgraph-host` and `contextgraph-conformance` did not compile from a half-applied merge of #37 (egress-scope + consent receipts): `host.rs` used `ConsentReceipt`/`EgressScope` without importing them and a `DataFlow` literal