- name
- changelog
- description
- Generate a user-facing changelog from recent commits/PRs across watched repos - write it in-repo (Keep a Changelog format) or open a cross-repo changelog PR on a docs/marketing repo.
- metadata
- {"title":"Changelog","category":"dev","var":"","tags":["dev","content","build"],"mode":"write","commits":true,"permissions":["contents:write","pull-requests:write"],"requires":["GH_GLOBAL?"]}
<!-- autoresearch: variation B — sharper output: Keep a Changelog categories, breaking-change surfacing, plain-English rewrites, noise filtering -->
> **${var}** — Selects the mode and target:
> - **empty** → in-repo changelog across every repo in `memory/watched-repos.md`.
> - **`owner/repo`** (bare slug) → in-repo changelog for that single repo only.
> - **`push-to:owner/website-repo`** → cross-repo mode: publish the product's merged PRs as a changelog PR on `owner/website-repo` (product repo comes from `memory/docs-sync.md`).
> - **`owner/product->owner/website`** (arrow form) → cross-repo mode with both product and website repos given explicitly.
## Why this skill exists
A changelog is not a commit log. Raw commit dumps grouped by conventional prefix are the noise anti-pattern — users can't tell what matters. This skill produces a [Keep a Changelog](https://keepachangelog.com/en/1.1.0/)-style summary: categorized, plain-English, breaking changes surfaced, internal churn filtered out. It runs in two modes: **in-repo** (write the article into this repo) or **push-to** (open a changelog PR on a separate marketing/docs website repo).
---
## 0. Preamble — read memory and parse the selector
Read `memory/MEMORY.md` and the last 3 days of `memory/logs/` for context (prior runs, known issues). Before notifying, drop anything already reported in that window.
Parse `${var}` to pick the branch:
| `${var}` | Branch | Target |
|----------|--------|--------|
| empty | **A — in-repo** | all repos in `memory/watched-repos.md` |
| `owner/repo` (no `push-to:`, no `->`) | **A — in-repo** | only that repo |
| `push-to:owner/website-repo` | **B — push-to** | website = `owner/website-repo`; product = config `product_repo` |
| `owner/product->owner/website` | **B — push-to** | product = `owner/product`; website = `owner/website` |
Disambiguation: a `push-to:` prefix **or** a `->` arrow selects Branch B; anything else (empty or a bare `owner/repo`) selects Branch A. Then jump to the matching branch below.
---
# Branch A — in-repo changelog
Writes a categorized changelog article into this repo. No cross-repo PR; `GH_GLOBAL` is not needed here.
## A.Config
Reads repos from `memory/watched-repos.md`. If the file doesn't exist, abort and notify: "changelog: `memory/watched-repos.md` missing — nothing to scan." Do not create it silently.
```markdown
# memory/watched-repos.md
- owner/repo
- another-owner/another-repo
```
If `${var}` is set to a bare `owner/repo`, scan only that repo (skip the file list).
### A.1. Pick the scan set
- If `${var}` is a bare `owner/repo`, scan only `${var}`.
- Otherwise, read `memory/watched-repos.md` and parse `- owner/repo` lines.
- If the list is empty, notify "changelog: no repos configured" and exit cleanly.
### A.2. Fetch commits and merged PRs per repo
For each repo, isolate failures — one broken repo must not kill the run. Track status in a `sources` dict (`repo → ok|empty|fail`).
Compute `SINCE` as UTC 7 days ago:
```bash
SINCE=$(date -u -d '7 days ago' +%Y-%m-%dT%H:%M:%SZ 2>/dev/null || date -u -v-7d +%Y-%m-%dT%H:%M:%SZ)
```
Detect the default branch (don't assume `main`):
```bash
BRANCH=$(gh repo view owner/repo --json defaultBranchRef --jq '.defaultBranchRef.name')
```
Fetch commits on the default branch since `SINCE`:
```bash
gh api -X GET "repos/owner/repo/commits" -f sha="$BRANCH" -f since="$SINCE" --paginate \
--jq '.[] | {sha: .sha, short: .sha[0:7], message: .commit.message, author: (.author.login // .commit.author.name), date: .commit.author.date, url: .html_url}'
```
Also fetch merged PRs in the window — PR titles/bodies are usually cleaner than raw commit messages:
```bash
gh pr list --repo owner/repo --state merged --limit 100 \
--search "merged:>=$SINCE" \
--json number,title,body,mergedAt,author,url,labels
```
**Network note:** `gh` uses `GITHUB_TOKEN` internally and works in a GitHub Actions run. If `gh` fails, log `fail` for that repo and continue — do not fall back to WebFetch (public API is rate-limited and adds noise).
### A.3. Filter noise
Exclude before classifying:
- Bot authors: `dependabot[bot]`, `renovate[bot]`, `claude[bot]`, `github-actions[bot]`.
- Merge commits where the underlying PR commits are already included (dedupe by PR number).
- Revert commits paired with the reverted commit in the same window (collapse both into a single "Reverted: X" Fixed entry, or drop if trivial).
- Pure auto-generated commits: "Update submodule", "Bump version to X", release-bot tags.
Keep a per-repo count of filtered commits for the footer ("N internal/bot commits hidden").
### A.4. Classify into Keep a Changelog categories
Do **not** use Features/Fixes/Docs/Chores — those are for developers. Use:
| Category | Use for |
|----------|---------|
| **⚠️ Breaking** | `feat!:` / `fix!:` / any commit whose body contains `BREAKING CHANGE:`. Also any removed public API. |
| **Added** | New user-visible features (typically `feat:` without `!`). |
| **Changed** | Modifications to existing functionality users will notice (behaviour, UX, defaults). |
| **Fixed** | Bug fixes users care about (`fix:` only if the bug was observable). |
| **Security** | `security:` prefix, `CVE-`, dependency bumps flagged as security, or commits touching auth/crypto with obvious security framing. |
| **Internal** | Everything else (`chore`, `ci`, `build`, `test`, `refactor`, `style`, `docs` unless docs are user-facing). Show only a one-line count, not full entries. |
`Deprecated` and `Removed` categories: include only if genuinely present — don't pad with empty sections.
### A.5. Rewrite each entry in user language
Commit message → changelog line rules:
- Strip the `type(scope):` prefix. Keep scope only if it clarifies (`dashboard: add dark mode` is fine; `core: fix bug` is not).
- Rewrite imperative dev-speak into a past-tense user statement: `feat(auth): add oauth2 pkce flow` → `OAuth 2 PKCE login is now supported.`
- Collapse related commits into one entry when they share a PR or scope (e.g. 4 commits for one feature → one line, list the shas in parentheses).
- Length: one sentence, ≤20 words per entry. Cut internal implementation details.
- Include one linked reference per entry: prefer PR (`[#123](url)`) over sha; fall back to short sha (`[a1b2c3d](url)`).
### A.6. Assemble the article
Save to `output/articles/changelog-${today}.md`:
```markdown
# Changelog — Week of ${today}
*Window: ${SINCE_date} → ${today} · Sources: repo1=ok, repo2=empty, repo3=fail*
## owner/repo
> **Highlights:** ≤2 sentences naming the most important user-facing change(s). If nothing user-facing, write "No user-facing changes this week; N internal commits."
### ⚠️ Breaking
- Plain-English breaking change description. Migration hint if obvious. ([#123](url))
### Added
- User-facing feature description. ([#124](url))
### Changed
- Behaviour/UX change. ([a1b2c3d](url))
### Fixed
- Bug that users would have hit. ([#125](url))
### Security
- Patch description, CVE if known. ([a1b2c3d](url))
*Internal: N commits hidden (chore/ci/build/refactor). Bots filtered: M.*
---
## owner/repo2
…
```
Rules:
- Omit categories that are empty (don't print "### Added\n- None").
- Omit entire repo section if `sources[repo] == empty` and no Highlights line is meaningful — but still list the repo in the sources line.
- If `sources[repo] == fail`, include a stub: `## owner/repo\n\n*Could not fetch — see logs.*`
### A.7. Notify
Send one concise paragraph via `./notify`:
```
*Changelog — Week of ${today}*
${total_repos} repos: ${total_user_facing} user-facing changes (${breaking_count} breaking, ${added_count} added, ${fixed_count} fixed, ${security_count} security). Top: ${one_line_most_important_change}. Full: output/articles/changelog-${today}.md
```
If zero user-facing changes across all repos: send `CHANGELOG_QUIET — no user-facing changes across ${N} repos this week.`
If all repos failed: send `CHANGELOG_ERROR — all ${N} repos failed to fetch. See logs.` and exit non-zero.
Then log (see the shared **Log** section) with `Mode: in-repo`.
---
# Branch B — push-to (cross-repo changelog PR)
Takes the product's recently merged PRs and publishes them as a **changelog** on the product's marketing/docs website, via a branch + PR on the website repo. The website is the public face — this keeps "what shipped" visible without anyone hand-writing release notes. **This branch opens a cross-repo PR and requires `GH_GLOBAL`** (a token with cross-repo write to the website repo). `GITHUB_TOKEN` alone only covers the current repo and cannot push to the website.
This branch is **config-driven** so the same file works in every instance. It reads which repos to use from `memory/docs-sync.md`; it never hardcodes repo names, handles, or commit identities.
## B.0. Resolve config
Read `memory/docs-sync.md`. It defines:
- `product_repo` — the repo whose merged PRs become the changelog (e.g. `owner/product`).
- `website_repo` — the Next.js marketing site to update (e.g. `owner/product-website`).
- `min_prs` (optional, default `1`) — minimum number of *new* unpublished PRs required to publish an entry.
- `lookback_days` (optional, default `7`) — only consider PRs merged within this many days. Bounds each entry to one window so a run never sweeps in months of history; matches the weekly schedule.
- `draft` (optional, default `true`) — open the website PR as a draft.
- `git_user_name` / `git_user_email` (optional) — commit identity for the website PR. Defaults to `aeon` / `aeon@users.noreply.github.com`.
Apply the selector on top of config:
- `push-to:owner/website-repo` → `website_repo = owner/website-repo`; `product_repo` from config.
- `owner/product->owner/website` → `product_repo = owner/product`, `website_repo = owner/website` (overrides config for this run).
If neither the selector nor `memory/docs-sync.md` yields **both** a product repo and a website repo, exit with `DOCS_SYNC_NO_CONFIG` (notify + log, no PR). Seed a `memory/docs-sync.md` template (commented placeholder rows) so the operator can fill it in.
## B.1. Gather merged PRs from the product repo
Compute the window cutoff first — `lookback_days` ago (default 7), as an ISO timestamp:
```bash
SINCE=$(date -u -d "${LOOKBACK_DAYS:-7} days ago" +%Y-%m-%dT%H:%M:%SZ 2>/dev/null || date -u -v-"${LOOKBACK_DAYS:-7}"d +%Y-%m-%dT%H:%M:%SZ)
```
Then fetch the last 50 closed PRs and keep only those **merged within the window**, newest merge first:
```bash
gh api "repos/${PRODUCT_REPO}/pulls" -X GET -f state=closed -f sort=updated -f direction=desc -f per_page=50 \
--jq "[.[] | select(.merged_at != null) | select(.merged_at > \"$SINCE\") | {number, title, url: .html_url, author: .user.login, merged_at, labels: [.labels[].name], body: (.body // \"\" | .[0:500])}] | sort_by(.merged_at) | reverse"
```
The window is the primary filter; the published-PR dedup in step B.2 is the idempotency guard against overlap and re-runs. Sandbox: if `gh api` fails transiently, retry once. Never use `curl` for the GitHub API — `gh` handles auth.
## B.2. Read what's already published (idempotency)
Clone the website repo and read the existing changelog data:
```bash
WORK_DIR="/tmp/docs-sync-work"
rm -rf "$WORK_DIR"
gh repo clone "$WEBSITE_REPO" "$WORK_DIR" -- --depth 20
cd "$WORK_DIR"
git config user.name "$GIT_USER_NAME"
git config user.email "$GIT_USER_EMAIL"
```
**Pin the commit identity in the clone.** A freshly cloned repo does NOT inherit the workflow's git identity, so without these two lines the commit author falls back to an improvised/unlinked email. Set `GIT_USER_NAME` / `GIT_USER_EMAIL` from `memory/docs-sync.md` (`git_user_name` / `git_user_email`); when the config omits them, default to `aeon` / `aeon@users.noreply.github.com`. Always pin it so every changelog commit + PR is attributed to one stable, intentional identity — never an improvised one.
If `app/changelog-data.ts` exists, read it and collect `PUBLISHED_PR_NUMBERS` (every PR number already in `CHANGELOG`). If it doesn't exist yet, this is a **bootstrap** run (see step B.4) and nothing is published.
**Compute the new set:** from step B.1's windowed PRs, keep only those whose `number` is NOT in `PUBLISHED_PR_NUMBERS`. PR number is the idempotency key — not dates — so re-running within the same window is always safe and never duplicates.
- If the new set is empty → exit `DOCS_SYNC_NOTHING_NEW` (silent: log only, no PR, no notify).
- If `0 < count < min_prs` → exit `DOCS_SYNC_BELOW_THRESHOLD` (log only, no PR). Lets PRs accumulate into a meaningful entry.
GitHub에서 보기