diff --git a/.github/workflows/ccip-release-changelog.yml b/.github/workflows/ccip-release-changelog.yml new file mode 100644 index 00000000000..36d67a3eb0d --- /dev/null +++ b/.github/workflows/ccip-release-changelog.yml @@ -0,0 +1,88 @@ +name: ccip-release-changelog + +on: + workflow_dispatch: + inputs: + old_ref: + description: "Old git ref (SHA, tag, or branch) that built the current release image, e.g. v2.55.0" + required: true + type: string + new_ref: + description: "New git ref (SHA, tag, or branch) for the new release image, e.g. release/2.56.0" + required: true + type: string + slack_thread_url: + description: "Optional Slack thread URL to post the summary + full report into" + required: false + type: string + # Reserved for a future Slack slash-command trigger posting a + # repository_dispatch with client_payload: {old_ref, new_ref, slack_thread_url}. + repository_dispatch: + types: [ccip-release-changelog] + +permissions: {} + +jobs: + changelog: + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - name: Resolve inputs (workflow_dispatch or repository_dispatch) + id: inputs + env: + IN_OLD: ${{ inputs.old_ref }} + IN_NEW: ${{ inputs.new_ref }} + IN_THREAD: ${{ inputs.slack_thread_url }} + PAYLOAD_OLD: ${{ github.event.client_payload.old_ref }} + PAYLOAD_NEW: ${{ github.event.client_payload.new_ref }} + PAYLOAD_THREAD: ${{ github.event.client_payload.slack_thread_url }} + run: | + old="${IN_OLD:-$PAYLOAD_OLD}" + new="${IN_NEW:-$PAYLOAD_NEW}" + thread="${IN_THREAD:-$PAYLOAD_THREAD}" + if [ -z "$old" ] || [ -z "$new" ]; then + echo "::error::old_ref and new_ref are required" + exit 1 + fi + echo "old_ref=$old" >> "$GITHUB_OUTPUT" + echo "new_ref=$new" >> "$GITHUB_OUTPUT" + echo "slack_thread_url=$thread" >> "$GITHUB_OUTPUT" + # Artifact names may not contain / : " < > | * ? etc. + echo "safe_old=${old//[^a-zA-Z0-9._-]/-}" >> "$GITHUB_OUTPUT" + echo "safe_new=${new//[^a-zA-Z0-9._-]/-}" >> "$GITHUB_OUTPUT" + + - uses: actions/checkout@v6 + with: + fetch-depth: 0 # full history + tags for ref resolution and git log + + - uses: actions/setup-go@v6 + with: + go-version-file: go.mod + + - name: Generate changelog + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} # compare API on public CCIP repos + SLACK_BOT_TOKEN: ${{ secrets.SLACK_BOT_TOKEN_RELENG }} + SLACK_THREAD: ${{ steps.inputs.outputs.slack_thread_url }} + OLD_REF: ${{ steps.inputs.outputs.old_ref }} + NEW_REF: ${{ steps.inputs.outputs.new_ref }} + run: | + args=(--old "$OLD_REF" --new "$NEW_REF" --out ccip-release-changelog.md) + if [ -n "$SLACK_THREAD" ]; then + if [ -z "$SLACK_BOT_TOKEN" ]; then + echo "::error::slack_thread_url given but SLACK_BOT_TOKEN_RELENG secret is not available" + exit 1 + fi + args+=(--slack-thread "$SLACK_THREAD") + fi + go run ./tools/ccip/ccip-release-changelog/cmd/ccip-release-changelog "${args[@]}" + + - name: Upload report artifact + if: always() + uses: actions/upload-artifact@v7 + with: + name: ccip-release-changelog-${{ steps.inputs.outputs.safe_old }}-${{ steps.inputs.outputs.safe_new }} + path: ccip-release-changelog.md + retention-days: 90 + if-no-files-found: warn diff --git a/tools/README.md b/tools/README.md index 500a34f550d..054f568921b 100644 --- a/tools/README.md +++ b/tools/README.md @@ -7,3 +7,9 @@ Manage Docker for development and testing ## [test](./test/) A harness for running /chainlink tests. From the repo root use **`make test`** (see [tools/test/README.md](./test/README.md)), e.g. `make test ARGS="./core/..."`. + +## [ccip-release-changelog](./ccip/ccip-release-changelog/) + +Generates a CCIP-focused release changelog and risk audit between two refs of +this repo (go.mod + plugins.public.yaml diffs, per-repo commit changelogs, +risk flags), optionally posted to a Slack thread. diff --git a/tools/ccip/ccip-release-changelog/README.md b/tools/ccip/ccip-release-changelog/README.md new file mode 100644 index 00000000000..c6a575fdf58 --- /dev/null +++ b/tools/ccip/ccip-release-changelog/README.md @@ -0,0 +1,133 @@ +# ccip-release-changelog + +Generates a CCIP-focused release changelog between two refs of this repo +(SHAs, tags like `v2.55.0`, or release branches like `release/2.56.0`), for +use as a risk-audit artifact in the release process. + +For each ref pair it produces: + +1. **go.mod diff** of the CCIP-relevant modules (chainlink-ccip and its + `chains/*` submodules, chainlink-evm and its submodules, + chainlink-aptos/codec, chainlink-sui/codec, chainlink-ton). +2. **Commit changelog for [chainlink-ccip](https://github.com/smartcontractkit/chainlink-ccip)** + between the pinned commits. +3. **Commit changelogs for the chain-specific repos** (chainlink-aptos, + chainlink-sui, chainlink-solana, chainlink-ton, chainlink-evm) between the + pinned commits, plus the core repo restricted to `core/capabilities/ccip/`. +4. **Risk flags**: plugin gitRef changes in `plugins/plugins.public.yaml`, + plugin-vs-go.mod drift (TON, EVM), rollbacks/divergence, modules or plugins + added/removed, and keyword callouts (`breaking`, `revert`, `hotfix`, + `security`, `fix!`, `config`). + +## Usage + +From the repo root (requires a full git history checkout): + +``` +go run ./tools/ccip/ccip-release-changelog/cmd/ccip-release-changelog \ + --old v2.55.0 --new release/2.56.0 \ + [--out report.md] [--slack-thread https://.slack.com/archives//p] +``` + +Refs can be SHAs, tags, or branch names. If a branch only exists remotely +(e.g. `release/2.56.0` has never been checked out locally), the tool +automatically falls back to `origin/`. + +Environment: + +- `GITHUB_TOKEN` / `GH_TOKEN` — GitHub compare API auth. Falls back to + `gh auth token`. All tracked repos are public, so this only affects rate + limits. +- `SLACK_BOT_TOKEN` — required with `--slack-thread`. The bot must be a member + of the target channel. The summary and flags are posted as a message in the + thread; the full markdown report is uploaded as a file in the same thread. + +In CI, use the `ccip-release-changelog` workflow (workflow_dispatch) which +takes the same inputs and uses the `SLACK_BOT_TOKEN_RELENG` secret. + +## Configuration + +All tracking behavior lives in one place: the `TrackedRepos` variable in +[`internal/changelog/config.go`](./internal/changelog/config.go). There are +**no CLI flags or workflow inputs for tracking** — edit that file and +re-run. Each entry is a `RepoConfig`: + +| Field | Meaning | +|---|---| +| `Name` / `Owner` | GitHub repo (`Owner/Name`). Used for the compare API call and for commit/PR links. | +| `GoModules` | Module paths in the **root `go.mod`** that come from this repo, most important first. All of them appear in the *go.mod changes* section and participate in divergence notes. | +| `PluginKeys` | Keys in `plugins/plugins.public.yaml` that install from this repo (e.g. `ton`, `evm`). | +| `IncludePaths` | If non-empty, only commits touching at least one of these path prefixes appear in the commit changelog. | +| `ExcludePaths` | Commits touching *only* these path prefixes are dropped (applied before `IncludePaths`). | +| `Local` | Read the commit log from the local git checkout instead of the compare API. Use for the core repo itself. | + +### How the "primary pin" is chosen + +The commit changelog compares exactly one old/new SHA pair per repo: + +- If `PluginKeys` is set, the **plugin gitRef** is primary — that's what gets + built into the release image (aptos, sui, solana, ton, evm). +- Otherwise the **first entry of `GoModules`** is primary (chainlink-ccip). + +Consequences: + +- **Dual-source drift flag** — when a repo has both a plugin entry and its + `moduleURI` also listed in `GoModules` (today: ton, evm), the two SHAs must + agree at each ref or a `DRIFT` flag is raised. +- **Divergence notes** — when a repo's various pins point at *different + commits of the same repo* (e.g. `chainlink-ccip` vs + `chainlink-ccip/chains/evm`, or `plugin:sui` vs `chainlink-sui/codec`), a + note is rendered in that repo's section. These are informational, not + flags, and are normal for repos that ship mixed pins. +- Only the primary pin's SHA range gets a commit changelog. Submodule bumps + still show up in the go.mod diff section. + +### Editing examples + +**Add a new repo** (shows up in all sections; primary = plugin gitRef if it +has one, else first GoModule): + +```go +{ + Name: "chainlink-tron", + Owner: "smartcontractkit", + GoModules: []string{"github.com/smartcontractkit/chainlink-tron/relayer"}, +}, +``` + +**Change which core-repo paths are tracked** — edit the `Local` entry's +filters, e.g. to also include CCIP deployment code: + +```go +IncludePaths: []string{"core/capabilities/ccip/", "deployment/ccip/"}, +``` + +**Stop tracking a module** — remove it from `GoModules` (see the +`contracts/cre/gobindings` comment in the chainlink-evm entry for precedent). + +### After editing + +The golden tests render from `TrackedRepos`, so they must be regenerated: + +``` +UPDATE_GOLDEN=1 go test ./tools/ccip/ccip-release-changelog/... +go test ./tools/ccip/ccip-release-changelog/... +golangci-lint run ./tools/ccip/ccip-release-changelog/... +``` + +Then sanity-check a real run, e.g. `--old v2.55.0 --new release/2.56.0`. + +### Related knobs (not in config.go) + +- Keyword callout pattern (`breaking|revert|hotfix|security|config|fix!`): + `keywordPattern` in + [`internal/changelog/analyze.go`](./internal/changelog/analyze.go). +- Slack message/markdown layout: `report.go`; compare-API behavior: + `github.go`. + +## Development + +``` +go test ./tools/ccip/ccip-release-changelog/... # unit + golden tests +UPDATE_GOLDEN=1 go test ./tools/ccip/ccip-release-changelog/... # regen goldens +``` diff --git a/tools/ccip/ccip-release-changelog/cmd/ccip-release-changelog/main.go b/tools/ccip/ccip-release-changelog/cmd/ccip-release-changelog/main.go new file mode 100644 index 00000000000..7b3f632dfc6 --- /dev/null +++ b/tools/ccip/ccip-release-changelog/cmd/ccip-release-changelog/main.go @@ -0,0 +1,114 @@ +// Command ccip-release-changelog generates a CCIP-focused changelog between +// two refs of the core chainlink repo (release branches, tags, or SHAs). +// +// It diffs the CCIP-relevant pins in go.mod and plugins/plugins.public.yaml, +// produces per-repo commit changelogs via the GitHub compare API (or local +// git for the core repo), flags release risks, and optionally posts the +// result into a Slack thread. +// +// Usage: +// +// ccip-release-changelog --old v2.55.0 --new release/2.56.0 \ +// [--repo .] [--out changelog.md] [--slack-thread https://...] +// +// Environment: +// +// GITHUB_TOKEN / GH_TOKEN - for the GitHub compare API (falls back to +// `gh auth token`; all tracked repos are public) +// SLACK_BOT_TOKEN - required when --slack-thread is given +package main + +import ( + "context" + "errors" + "flag" + "fmt" + "os" + "os/signal" + + "github.com/smartcontractkit/chainlink/v2/tools/ccip/ccip-release-changelog/internal/changelog" +) + +func main() { + os.Exit(runMain()) +} + +func runMain() int { + oldRef := flag.String("old", "", "old git ref (SHA, tag, or branch) that built the current release image") + newRef := flag.String("new", "", "new git ref (SHA, tag, or branch) for the new release image") + repoDir := flag.String("repo", ".", "path to the chainlink core repo checkout") + outPath := flag.String("out", "", "write the full markdown report to this file (default: stdout)") + slackThread := flag.String("slack-thread", "", "optional Slack thread URL to post the summary and report into") + flag.Parse() + + if *oldRef == "" || *newRef == "" { + fmt.Fprintln(os.Stderr, "error: --old and --new are required") + flag.Usage() + return 2 + } + + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt) + defer stop() + + if err := run(ctx, *repoDir, *oldRef, *newRef, *outPath, *slackThread); err != nil { + fmt.Fprintf(os.Stderr, "error: %v\n", err) + return 1 + } + return 0 +} + +func run(ctx context.Context, repoDir, oldRef, newRef, outPath, slackThreadURL string) error { + report, err := changelog.Generate(ctx, repoDir, oldRef, newRef) + if err != nil { + return err + } + + markdown := changelog.RenderMarkdown(report) + + if outPath != "" { + if err := os.WriteFile(outPath, []byte(markdown), 0o600); err != nil { + return fmt.Errorf("writing %s: %w", outPath, err) + } + fmt.Fprintf(os.Stderr, "report written to %s\n", outPath) + } else if slackThreadURL == "" { + fmt.Print(markdown) + } + + if slackThreadURL != "" { + token := os.Getenv("SLACK_BOT_TOKEN") + if token == "" { + return errors.New("--slack-thread requires SLACK_BOT_TOKEN in the environment") + } + thread, err := changelog.ParseSlackThreadURL(slackThreadURL) + if err != nil { + return err + } + summary := changelog.RenderSlackSummary(report) + filename := fmt.Sprintf("ccip-release-changelog-%s-%s.md", + changelog.SanitizeForFilename(oldRef), changelog.SanitizeForFilename(newRef)) + title := fmt.Sprintf("CCIP Release Changelog %s → %s", oldRef, newRef) + + // The summary is the audit payload: if it can't be delivered, fail. + if err := changelog.PostSummary(ctx, token, thread, summary); err != nil { + return fmt.Errorf("posting summary to Slack: %w", err) + } + + // The file upload needs the files:write scope, which the bot token + // may not have. Degrade gracefully: point the thread at the CI + // artifact instead, and don't fail the run — the report content is + // already on stdout / in --out. + if err := changelog.UploadReport(ctx, token, thread, filename, title, markdown); err != nil { + fallback := fmt.Sprintf("⚠️ Full report upload failed (%v).", err) + if url := changelog.ActionsRunURL(); url != "" { + fallback += " The markdown report is attached to this CI run as an artifact: " + url + } + if ferr := changelog.PostSummary(ctx, token, thread, fallback); ferr != nil { + return fmt.Errorf("uploading report: %w; posting fallback message: %w", err, ferr) + } + fmt.Fprintf(os.Stderr, "warning: report upload failed (%v); summary posted to thread %s#%s\n", err, thread.Channel, thread.ThreadTS) + return nil + } + fmt.Fprintf(os.Stderr, "posted summary and full report to Slack thread %s#%s\n", thread.Channel, thread.ThreadTS) + } + return nil +} diff --git a/tools/ccip/ccip-release-changelog/internal/changelog/analyze.go b/tools/ccip/ccip-release-changelog/internal/changelog/analyze.go new file mode 100644 index 00000000000..0c8382e273e --- /dev/null +++ b/tools/ccip/ccip-release-changelog/internal/changelog/analyze.go @@ -0,0 +1,322 @@ +package changelog + +import ( + "context" + "fmt" + "regexp" + "strings" + "time" +) + +// Report is the full changelog analysis between two core-repo refs. +type Report struct { + Old, New DepSnapshot + Repos []RepoReport + Flags []string // top-level audit flags + Generated time.Time +} + +// RepoReport is the per-repository analysis. +type RepoReport struct { + Config RepoConfig + Old, New repoPin + Status string // ahead / behind / diverged / identical / "" when unknown + TotalInRange int // commits in range before path filtering + Commits []CommitEntry + KeywordHits []CommitEntry + Notes []string // low-severity observations (rendered in repo section) + Err string // set when the commit changelog could not be produced + Truncated bool // compare API commit list was capped +} + +// keywordPattern flags commit titles interesting to a release audit. +var keywordPattern = regexp.MustCompile(`(?i)\b(breaking|revert|hotfix|security|config)\b|fix!`) + +// Analyze computes the full report between two core-repo refs. +// gh may be nil if only local processing is desired (external repos will +// record errors instead of commit logs). +func Analyze(ctx context.Context, g gitRunner, gh *ghClient, oldRef, newRef string) (*Report, error) { + oldSnap, err := LoadSnapshot(ctx, g, oldRef) + if err != nil { + return nil, fmt.Errorf("loading old snapshot: %w", err) + } + newSnap, err := LoadSnapshot(ctx, g, newRef) + if err != nil { + return nil, fmt.Errorf("loading new snapshot: %w", err) + } + + rep := &Report{Old: oldSnap, New: newSnap, Generated: time.Now().UTC()} + + for _, cfg := range TrackedRepos { + rr := analyzeRepo(ctx, g, gh, cfg, oldSnap, newSnap) + rep.Repos = append(rep.Repos, rr) + rep.Flags = append(rep.Flags, repoFlags(rr, oldSnap, newSnap)...) + } + return rep, nil +} + +func analyzeRepo(ctx context.Context, g gitRunner, gh *ghClient, cfg RepoConfig, oldSnap, newSnap DepSnapshot) RepoReport { + rr := RepoReport{Config: cfg, Old: pinFor(cfg, oldSnap), New: pinFor(cfg, newSnap)} + + if cfg.Local { + analyzeLocal(ctx, g, &rr, oldSnap.SHA, newSnap.SHA) + } else { + analyzeRemote(ctx, gh, &rr) + } + + for _, c := range rr.Commits { + if keywordPattern.MatchString(c.Title) { + rr.KeywordHits = append(rr.KeywordHits, c) + } + } + rr.Notes = append(rr.Notes, dedupeDivergenceNotes(cfg, rr.Old, rr.New)...) + return rr +} + +// dedupeDivergenceNotes emits divergence notes for both refs, collapsing to a +// single "(both refs)" note when the divergence is identical at each end. +func dedupeDivergenceNotes(cfg RepoConfig, oldPin, newPin repoPin) []string { + oldNotes := divergenceNotes(cfg, oldPin, "old") + newNotes := divergenceNotes(cfg, newPin, "new") + if len(oldNotes) == 1 && len(newNotes) == 1 { + oldBody := strings.TrimPrefix(oldNotes[0], "divergent pins (old ref): ") + newBody := strings.TrimPrefix(newNotes[0], "divergent pins (new ref): ") + if oldBody == newBody { + return []string{"divergent pins (both refs): " + oldBody} + } + } + return append(oldNotes, newNotes...) +} + +// analyzeLocal produces the commit changelog for the core repo from the local +// checkout, applying path filters. +func analyzeLocal(ctx context.Context, g gitRunner, rr *RepoReport, oldSHA, newSHA string) { + rr.Old.PrimarySHA, rr.New.PrimarySHA = oldSHA, newSHA + if oldSHA == newSHA { + rr.Status = "identical" + return + } + switch { + case g.IsAncestor(ctx, oldSHA, newSHA): + rr.Status = "ahead" + case g.IsAncestor(ctx, newSHA, oldSHA): + rr.Status = "behind" // new ref is strictly older: genuine rollback + default: + // Release branches diverge from each other by design; unlike an + // external pin, this is normal for the core repo. LogRange still + // yields exactly "what's in new that wasn't in old". + rr.Status = "ahead" + rr.Notes = append(rr.Notes, "refs are not in direct ancestry (e.g. different release lines); listing commits reachable from new but not old") + } + all, err := g.LogRange(ctx, oldSHA, newSHA) + if err != nil { + rr.Err = fmt.Sprintf("git log failed: %v", err) + return + } + rr.TotalInRange = len(all) + for _, c := range all { + if len(rr.Config.IncludePaths) > 0 || len(rr.Config.ExcludePaths) > 0 { + files, err := g.CommitFiles(ctx, c.SHA) + if err != nil { + rr.Notes = append(rr.Notes, fmt.Sprintf("could not list files of %s: %v", shortSHA(c.SHA), err)) + continue + } + if !pathMatch(files, rr.Config.IncludePaths, rr.Config.ExcludePaths) { + continue + } + } + title, pr := parseTitle(c.Title) + author := c.AuthorName + if login := noreplyLogin(c.AuthorEmail); login != "" { + author = "@" + login + } + rr.Commits = append(rr.Commits, CommitEntry{SHA: c.SHA, Title: title, PR: pr, Author: author}) + } +} + +// noreplyLogin extracts a GitHub login from a noreply email like +// "12345+octocat@users.noreply.github.com". +func noreplyLogin(email string) string { + const suffix = "@users.noreply.github.com" + if !strings.HasSuffix(email, suffix) { + return "" + } + local := strings.TrimSuffix(email, suffix) + if i := strings.LastIndex(local, "+"); i >= 0 { + return local[i+1:] + } + return local +} + +// pathMatch applies include/exclude prefix filters to a commit's file list. +func pathMatch(files, includes, excludes []string) bool { + kept := files[:0:0] + for _, f := range files { + excluded := false + for _, ex := range excludes { + if strings.HasPrefix(f, ex) { + excluded = true + break + } + } + if !excluded { + kept = append(kept, f) + } + } + if len(includes) == 0 { + return len(kept) > 0 + } + for _, f := range kept { + for _, in := range includes { + if strings.HasPrefix(f, in) { + return true + } + } + } + return false +} + +// analyzeRemote produces the commit changelog for an external repo via the +// GitHub compare API. +func analyzeRemote(ctx context.Context, gh *ghClient, rr *RepoReport) { + oldSHA, newSHA := rr.Old.PrimarySHA, rr.New.PrimarySHA + switch { + case rr.Old.PrimaryVersion == "" && rr.New.PrimaryVersion == "": + rr.Err = "no pin found at either ref" + return + case oldSHA == "" || newSHA == "": + if rr.Old.PrimaryVersion == rr.New.PrimaryVersion { + rr.Status = "identical" + return + } + rr.Err = fmt.Sprintf("could not extract commit SHA from version(s) %q / %q", + rr.Old.PrimaryVersion, rr.New.PrimaryVersion) + return + case oldSHA == newSHA: + rr.Status = "identical" + return + } + if gh == nil { + rr.Err = "no GitHub client configured" + return + } + res, err := gh.Compare(ctx, rr.Config.Owner, rr.Config.Name, oldSHA, newSHA) + if err != nil { + rr.Err = err.Error() + return + } + rr.Status = res.Status + // The compare API returns commits oldest-first; render newest-first. + for i, j := 0, len(res.Commits)-1; i < j; i, j = i+1, j-1 { + res.Commits[i], res.Commits[j] = res.Commits[j], res.Commits[i] + } + rr.Commits = res.Commits + rr.TotalInRange = res.TotalCommits + if rr.TotalInRange == 0 { + rr.TotalInRange = res.AheadBy + } + rr.Truncated = res.TotalCommits > len(res.Commits) +} + +// divergenceNotes reports when a repo's various pins at one ref point at +// different commits (multi-module divergence). +func divergenceNotes(cfg RepoConfig, pin repoPin, side string) []string { + seen := map[string]string{} // sha -> label + var order []string + add := func(label, version string) { + sha := VersionSHA(version) + if sha == "" { + return + } + if _, ok := seen[sha]; !ok { + seen[sha] = label + order = append(order, sha) + } + } + for _, m := range cfg.GoModules { + if v, ok := pin.ModuleVersions[m]; ok { + add(strings.TrimPrefix(m, "github.com/smartcontractkit/"), v) + } + } + for _, k := range cfg.PluginKeys { + if p, ok := pin.PluginRefs[k]; ok { + add("plugin:"+k, p.GitRef) + } + } + if len(order) <= 1 { + return nil + } + var parts []string + for _, sha := range order { + parts = append(parts, fmt.Sprintf("%s at `%s`", seen[sha], shortSHA(sha))) + } + return []string{fmt.Sprintf("divergent pins (%s ref): %s", side, strings.Join(parts, "; "))} +} + +// repoFlags computes the top-level audit flags for one repo. +func repoFlags(rr RepoReport, oldSnap, newSnap DepSnapshot) []string { + var flags []string + name := rr.Config.Name + + // Plugin gitRef changes (spec output #4). + for _, k := range rr.Config.PluginKeys { + oldP, oldOK := oldSnap.Plugins[k] + newP, newOK := newSnap.Plugins[k] + switch { + case oldOK && !newOK: + flags = append(flags, fmt.Sprintf("**%s**: plugin `%s` REMOVED from plugins.public.yaml (was `%s`)", name, k, oldP.GitRef)) + case !oldOK && newOK: + flags = append(flags, fmt.Sprintf("**%s**: plugin `%s` ADDED to plugins.public.yaml (`%s`)", name, k, newP.GitRef)) + case oldOK && oldP.GitRef != newP.GitRef: + flags = append(flags, fmt.Sprintf("**%s**: plugin `%s` gitRef changed `%s` → `%s`", name, k, oldP.GitRef, newP.GitRef)) + } + } + + // go.mod module added/removed. + for _, m := range rr.Config.GoModules { + oldV, oldOK := oldSnap.Modules[m] + newV, newOK := newSnap.Modules[m] + switch { + case oldOK && !newOK: + flags = append(flags, fmt.Sprintf("**%s**: go.mod module `%s` REMOVED (was `%s`)", name, shortModule(m), oldV)) + case !oldOK && newOK: + flags = append(flags, fmt.Sprintf("**%s**: go.mod module `%s` ADDED (`%s`)", name, shortModule(m), newV)) + } + } + + // Dual-source drift: plugin moduleURI matches a tracked go.mod module and + // their SHAs disagree at the same ref. + for _, k := range rr.Config.PluginKeys { + for side, snap := range map[string]DepSnapshot{"old": oldSnap, "new": newSnap} { + p, ok := snap.Plugins[k] + if !ok { + continue + } + if v, ok := snap.Modules[p.ModuleURI]; ok { + if ps, ms := VersionSHA(p.GitRef), VersionSHA(v); ps != "" && ms != "" && ps != ms { + flags = append(flags, fmt.Sprintf("**%s**: DRIFT at %s ref — plugin `%s` pins `%s` but go.mod `%s` pins `%s`", + name, side, k, shortSHA(ps), shortModule(p.ModuleURI), shortSHA(ms))) + } + } + } + } + + // Rollback / divergence. + switch rr.Status { + case "behind": + flags = append(flags, fmt.Sprintf("**%s**: ROLLBACK — new pin `%s` is BEHIND old pin `%s`", name, shortSHA(rr.New.PrimarySHA), shortSHA(rr.Old.PrimarySHA))) + case "diverged": + flags = append(flags, fmt.Sprintf("**%s**: DIVERGED — old and new pins share no direct ancestry (`%s` / `%s`)", name, shortSHA(rr.Old.PrimarySHA), shortSHA(rr.New.PrimarySHA))) + } + + // Keyword callouts. + for _, c := range rr.KeywordHits { + flags = append(flags, fmt.Sprintf("**%s**: keyword match — %s ([`%s`](https://github.com/%s/%s/commit/%s))", + name, c.Title, shortSHA(c.SHA), rr.Config.Owner, rr.Config.Name, c.SHA)) + } + return flags +} + +func shortModule(m string) string { + return strings.TrimPrefix(m, "github.com/smartcontractkit/") +} diff --git a/tools/ccip/ccip-release-changelog/internal/changelog/analyze_test.go b/tools/ccip/ccip-release-changelog/internal/changelog/analyze_test.go new file mode 100644 index 00000000000..07eb0b4c4f9 --- /dev/null +++ b/tools/ccip/ccip-release-changelog/internal/changelog/analyze_test.go @@ -0,0 +1,207 @@ +package changelog + +import ( + "strings" + "testing" +) + +func TestKeywordPattern(t *testing.T) { + t.Parallel() + + hits := []string{ + "fix!: correct CCIP nonce handling", + "BREAKING: change config format", + "Revert \"add lane config\"", + "hotfix for token pool", + "security patch for verifier", + "update chain config defaults", + "Config: bump lanes", + } + misses := []string{ + "feat: add new lane", + "chore: bump deps", + "reconfigure dashboards", // no word boundary match for "config" + "fix offramp bug", // no "!" suffix + "docs: update README", + } + for _, h := range hits { + if !keywordPattern.MatchString(h) { + t.Errorf("expected keyword hit for %q", h) + } + } + for _, m := range misses { + if keywordPattern.MatchString(m) { + t.Errorf("expected no keyword hit for %q", m) + } + } +} + +func TestPathMatch(t *testing.T) { + t.Parallel() + + includes := []string{"core/capabilities/ccip/"} + cases := []struct { + name string + files []string + includes []string + excludes []string + want bool + }{ + {"include hit", []string{"core/capabilities/ccip/ocr/plugin.go"}, includes, nil, true}, + {"include miss", []string{"core/services/relay/evm/evm.go"}, includes, nil, false}, + {"mixed files", []string{"README.md", "core/capabilities/ccip/x.go"}, includes, nil, true}, + {"no filters", []string{"anything.go"}, nil, nil, true}, + {"exclude only file", []string{"core/capabilities/ccip/gen/x.go"}, includes, []string{"core/capabilities/ccip/gen/"}, false}, + {"exclude partial", []string{"core/capabilities/ccip/gen/x.go", "core/capabilities/ccip/y.go"}, includes, []string{"core/capabilities/ccip/gen/"}, true}, + {"exclude only, no includes", []string{"docs/x.md"}, nil, []string{"docs/"}, false}, + {"empty files", nil, includes, nil, false}, + } + for _, c := range cases { + if got := pathMatch(c.files, c.includes, c.excludes); got != c.want { + t.Errorf("%s: pathMatch = %v, want %v", c.name, got, c.want) + } + } +} + +func TestNoreplyLogin(t *testing.T) { + t.Parallel() + + cases := map[string]string{ + "12345+octocat@users.noreply.github.com": "octocat", + "octocat@users.noreply.github.com": "octocat", + "real@example.com": "", + "": "", + } + for email, want := range cases { + if got := noreplyLogin(email); got != want { + t.Errorf("noreplyLogin(%q) = %q, want %q", email, got, want) + } + } +} + +func TestDivergenceNotes(t *testing.T) { + t.Parallel() + + cfg := RepoConfig{ + Name: "chainlink-ton", + GoModules: []string{"github.com/smartcontractkit/chainlink-ton"}, + PluginKeys: []string{"ton"}, + } + // Convergent: same SHA everywhere -> no note. + convergent := repoPin{ + ModuleVersions: map[string]string{"github.com/smartcontractkit/chainlink-ton": "v1.0.5-0.20260629213843-c52e07523035"}, + PluginRefs: map[string]PluginPin{"ton": {GitRef: "v1.0.5-0.20260629213843-c52e07523035"}}, + } + if notes := divergenceNotes(cfg, convergent, "new"); len(notes) != 0 { + t.Errorf("convergent pins produced notes: %v", notes) + } + // Divergent: plugin and module disagree -> one note naming both. + divergent := repoPin{ + ModuleVersions: map[string]string{"github.com/smartcontractkit/chainlink-ton": "v1.0.5-0.20260629213843-c52e07523035"}, + PluginRefs: map[string]PluginPin{"ton": {GitRef: "v1.0.5-0.20260701000000-aaaaaaaaaaaa"}}, + } + notes := divergenceNotes(cfg, divergent, "new") + if len(notes) != 1 { + t.Fatalf("expected 1 note, got %v", notes) + } + if !strings.Contains(notes[0], "c52e07523035") || !strings.Contains(notes[0], "aaaaaaaaaaaa") { + t.Errorf("note missing SHAs: %s", notes[0]) + } +} + +func TestRepoFlags(t *testing.T) { + t.Parallel() + + cfg := RepoConfig{ + Name: "chainlink-ton", + Owner: "smartcontractkit", + GoModules: []string{"github.com/smartcontractkit/chainlink-ton"}, + PluginKeys: []string{"ton"}, + } + mod := "github.com/smartcontractkit/chainlink-ton" + + t.Run("plugin gitRef changed", func(t *testing.T) { + t.Parallel() + + oldSnap := DepSnapshot{Plugins: map[string]PluginPin{"ton": {GitRef: "v1.0.5-0.20260629213843-c52e07523035", ModuleURI: mod}}} + newSnap := DepSnapshot{Plugins: map[string]PluginPin{"ton": {GitRef: "v1.0.5-0.20260701000000-aaaaaaaaaaaa", ModuleURI: mod}}} + rr := RepoReport{Config: cfg} + flags := repoFlags(rr, oldSnap, newSnap) + if len(flags) != 1 || !strings.Contains(flags[0], "gitRef changed") { + t.Errorf("unexpected flags: %v", flags) + } + }) + + t.Run("plugin removed and added", func(t *testing.T) { + t.Parallel() + + oldSnap := DepSnapshot{Plugins: map[string]PluginPin{"ton": {GitRef: "v1", ModuleURI: mod}}} + newSnap := DepSnapshot{Plugins: map[string]PluginPin{}} + flags := repoFlags(RepoReport{Config: cfg}, oldSnap, newSnap) + if len(flags) != 1 || !strings.Contains(flags[0], "REMOVED") { + t.Errorf("unexpected flags: %v", flags) + } + flags = repoFlags(RepoReport{Config: cfg}, newSnap, oldSnap) + if len(flags) != 1 || !strings.Contains(flags[0], "ADDED") { + t.Errorf("unexpected flags: %v", flags) + } + }) + + t.Run("dual-source drift", func(t *testing.T) { + t.Parallel() + + snap := DepSnapshot{ + Modules: map[string]string{mod: "v1.0.5-0.20260629213843-c52e07523035"}, + Plugins: map[string]PluginPin{"ton": {GitRef: "v1.0.5-0.20260701000000-bbbbbbbbbbbb", ModuleURI: mod}}, + } + flags := repoFlags(RepoReport{Config: cfg}, snap, snap) + if len(flags) != 2 { // same drift flagged at both refs + t.Fatalf("expected 2 drift flags, got %v", flags) + } + if !strings.Contains(flags[0], "DRIFT") { + t.Errorf("unexpected flag text: %s", flags[0]) + } + }) + + t.Run("no drift when SHAs match", func(t *testing.T) { + t.Parallel() + + snap := DepSnapshot{ + Modules: map[string]string{mod: "v1.0.5-0.20260629213843-c52e07523035"}, + Plugins: map[string]PluginPin{"ton": {GitRef: "v1.0.5-0.20260629213843-c52e07523035", ModuleURI: mod}}, + } + if flags := repoFlags(RepoReport{Config: cfg}, snap, snap); len(flags) != 0 { + t.Errorf("unexpected flags: %v", flags) + } + }) + + t.Run("rollback", func(t *testing.T) { + t.Parallel() + + rr := RepoReport{ + Config: cfg, + Status: "behind", + Old: repoPin{PrimarySHA: "aaaaaaaaaaaa"}, + New: repoPin{PrimarySHA: "bbbbbbbbbbbb"}, + } + flags := repoFlags(rr, DepSnapshot{}, DepSnapshot{}) + if len(flags) != 1 || !strings.Contains(flags[0], "ROLLBACK") { + t.Errorf("unexpected flags: %v", flags) + } + }) + + t.Run("keyword hits", func(t *testing.T) { + t.Parallel() + + rr := RepoReport{ + Config: cfg, + KeywordHits: []CommitEntry{ + {SHA: "abc123def456", Title: "hotfix for token pool"}, + }, + } + flags := repoFlags(rr, DepSnapshot{}, DepSnapshot{}) + if len(flags) != 1 || !strings.Contains(flags[0], "keyword match") { + t.Errorf("unexpected flags: %v", flags) + } + }) +} diff --git a/tools/ccip/ccip-release-changelog/internal/changelog/config.go b/tools/ccip/ccip-release-changelog/internal/changelog/config.go new file mode 100644 index 00000000000..c0f8c1f64cf --- /dev/null +++ b/tools/ccip/ccip-release-changelog/internal/changelog/config.go @@ -0,0 +1,97 @@ +package changelog + +// RepoConfig describes one repository tracked by the changelog tool. +// +// EDIT THIS LIST to add/remove repositories or tune which paths are tracked. +// Everything below is configuration: no CLI flags or Slack inputs are needed +// to change tracking behavior. +type RepoConfig struct { + // Name is the repository name (e.g. "chainlink-ccip"). + Name string + // Owner is the GitHub org/user (e.g. "smartcontractkit"). + Owner string + + // GoModules lists module paths in the root go.mod of the core repo that + // come from this repository, in decreasing importance order. The first + // entry is the "primary module" whose pin drives the commit changelog + // when the repo has no plugin entry. + GoModules []string + + // PluginKeys lists keys in plugins/plugins.public.yaml that install from + // this repository. When set, the plugin gitRef is what gets built into + // the release image, so it is the primary pin for the commit changelog. + PluginKeys []string + + // IncludePaths, when non-empty, restricts the commit changelog to commits + // touching at least one of these path prefixes. + IncludePaths []string + // ExcludePaths drops commits that only touch these path prefixes. + ExcludePaths []string + + // Local indicates the repository is the one this tool runs inside + // (the core chainlink repo). Its commit log is read from the local git + // checkout instead of the GitHub compare API. + Local bool +} + +// primaryIsPlugin reports whether the plugin gitRef (rather than a go.mod +// module pin) is the authoritative pin for this repo. +func (c RepoConfig) primaryIsPlugin() bool { return len(c.PluginKeys) > 0 } + +// TrackedRepos is the editable configuration block for the tool. +// +// To track an additional repo, add an entry. To narrow a repo's changelog to +// specific paths (like the core repo entry), set IncludePaths/ExcludePaths. +var TrackedRepos = []RepoConfig{ + { + Name: "chainlink-ccip", + Owner: "smartcontractkit", + GoModules: []string{ + "github.com/smartcontractkit/chainlink-ccip", // primary + "github.com/smartcontractkit/chainlink-ccip/chains/evm", + "github.com/smartcontractkit/chainlink-ccip/chains/solana", + "github.com/smartcontractkit/chainlink-ccip/chains/solana/gobindings", + }, + }, + { + Name: "chainlink-aptos", + Owner: "smartcontractkit", + GoModules: []string{"github.com/smartcontractkit/chainlink-aptos/codec"}, + PluginKeys: []string{"aptos"}, // primary pin + }, + { + Name: "chainlink-sui", + Owner: "smartcontractkit", + GoModules: []string{"github.com/smartcontractkit/chainlink-sui/codec"}, + PluginKeys: []string{"sui"}, // primary pin + }, + { + Name: "chainlink-solana", + Owner: "smartcontractkit", + PluginKeys: []string{"solana"}, // primary pin (not in root go.mod) + }, + { + Name: "chainlink-ton", + Owner: "smartcontractkit", + GoModules: []string{"github.com/smartcontractkit/chainlink-ton"}, + PluginKeys: []string{"ton"}, // dual-source: checked against go.mod + }, + { + Name: "chainlink-evm", + Owner: "smartcontractkit", + GoModules: []string{ + "github.com/smartcontractkit/chainlink-evm", + "github.com/smartcontractkit/chainlink-evm/gethwrappers", + // contracts/cre/gobindings intentionally not tracked (CRE-owned) + }, + PluginKeys: []string{"evm"}, // dual-source: checked against go.mod + }, + { + // The core repo itself, restricted to the CCIP capability tree. + Name: "chainlink", + Owner: "smartcontractkit", + IncludePaths: []string{"core/capabilities/ccip/"}, + ExcludePaths: []string{}, + Local: true, + }, +} diff --git a/tools/ccip/ccip-release-changelog/internal/changelog/deps.go b/tools/ccip/ccip-release-changelog/internal/changelog/deps.go new file mode 100644 index 00000000000..21e1ef858d7 --- /dev/null +++ b/tools/ccip/ccip-release-changelog/internal/changelog/deps.go @@ -0,0 +1,177 @@ +package changelog + +import ( + "context" + "fmt" + "regexp" + + "golang.org/x/mod/modfile" + "gopkg.in/yaml.v3" +) + +// PluginPin is one tracked entry of plugins.public.yaml. +type PluginPin struct { + GitRef string + ModuleURI string +} + +// DepSnapshot captures the CCIP-relevant dependency pins of the core repo at +// one git ref. +type DepSnapshot struct { + Ref string + SHA string + // Modules maps go.mod module path -> version string (tracked modules only). + Modules map[string]string + // Plugins maps plugins.public.yaml key -> plugin pin (tracked plugins only). + Plugins map[string]PluginPin +} + +var ( + // pseudoVersionSHA matches the trailing 12-hex-char commit SHA of a Go + // pseudo-version, e.g. "v0.1.1-solana.0.20260625091148-e5618f5682ee". + pseudoVersionSHA = regexp.MustCompile(`-([0-9a-f]{12})$`) + // rawSHA matches a full 40-char git SHA used directly as a gitRef. + rawSHA = regexp.MustCompile(`^[0-9a-f]{40}$`) +) + +// VersionSHA extracts a commit SHA from a Go pseudo-version or raw-SHA +// gitRef. Returns "" if no SHA can be extracted (e.g. a clean release tag). +func VersionSHA(version string) string { + if rawSHA.MatchString(version) { + return version + } + if m := pseudoVersionSHA.FindStringSubmatch(version); m != nil { + return m[1] + } + return "" +} + +// ParseGoMod parses a root go.mod and extracts the versions of all tracked +// modules (per TrackedRepos configuration). +func ParseGoMod(data []byte) (map[string]string, error) { + f, err := modfile.Parse("go.mod", data, nil) + if err != nil { + return nil, fmt.Errorf("parsing go.mod: %w", err) + } + tracked := map[string]bool{} + for _, repo := range TrackedRepos { + for _, m := range repo.GoModules { + tracked[m] = true + } + } + out := map[string]string{} + for _, req := range f.Require { + if tracked[req.Mod.Path] { + out[req.Mod.Path] = req.Mod.Version + } + } + return out, nil +} + +// pluginsFile mirrors the relevant structure of plugins/plugins.public.yaml. +type pluginsFile struct { + Plugins map[string][]struct { + ModuleURI string `yaml:"moduleURI"` + GitRef string `yaml:"gitRef"` + } `yaml:"plugins"` +} + +// ParsePluginsYAML parses plugins.public.yaml and extracts the pins of all +// tracked plugin keys. +func ParsePluginsYAML(data []byte) (map[string]PluginPin, error) { + var pf pluginsFile + if err := yaml.Unmarshal(data, &pf); err != nil { + return nil, fmt.Errorf("parsing plugins.public.yaml: %w", err) + } + tracked := map[string]bool{} + for _, repo := range TrackedRepos { + for _, k := range repo.PluginKeys { + tracked[k] = true + } + } + out := map[string]PluginPin{} + for key, entries := range pf.Plugins { + if !tracked[key] || len(entries) == 0 { + continue + } + out[key] = PluginPin{GitRef: entries[0].GitRef, ModuleURI: entries[0].ModuleURI} + } + return out, nil +} + +// LoadSnapshot resolves ref and loads the dependency snapshot at that ref +// from the local core-repo checkout. +func LoadSnapshot(ctx context.Context, g gitRunner, ref string) (DepSnapshot, error) { + sha, err := g.ResolveRef(ctx, ref) + if err != nil { + return DepSnapshot{}, err + } + // Read files at the resolved SHA, not the raw ref: the ref may have + // resolved via the origin/ fallback and not exist locally. + goMod, err := g.FileAtRef(ctx, sha, "go.mod") + if err != nil { + return DepSnapshot{}, err + } + plugins, err := g.FileAtRef(ctx, sha, "plugins/plugins.public.yaml") + if err != nil { + return DepSnapshot{}, err + } + modules, err := ParseGoMod(goMod) + if err != nil { + return DepSnapshot{}, err + } + pluginRefs, err := ParsePluginsYAML(plugins) + if err != nil { + return DepSnapshot{}, err + } + return DepSnapshot{ + Ref: ref, SHA: sha, + Modules: modules, Plugins: pluginRefs, + }, nil +} + +// repoPin holds the resolved pins of one tracked repo at one ref. +type repoPin struct { + // PrimaryVersion is the version string driving the changelog (plugin + // gitRef if the repo has plugin entries, else the primary go.mod module). + PrimaryVersion string + // PrimarySHA is the extracted commit SHA ("" if unparseable). + PrimarySHA string + // ModuleVersions maps module path -> version at this ref. + ModuleVersions map[string]string + // PluginRefs maps plugin key -> gitRef at this ref. + PluginRefs map[string]PluginPin +} + +func pinFor(cfg RepoConfig, snap DepSnapshot) repoPin { + p := repoPin{ + ModuleVersions: map[string]string{}, + PluginRefs: map[string]PluginPin{}, + } + for _, m := range cfg.GoModules { + if v, ok := snap.Modules[m]; ok { + p.ModuleVersions[m] = v + } + } + for _, k := range cfg.PluginKeys { + if v, ok := snap.Plugins[k]; ok { + p.PluginRefs[k] = v + } + } + switch { + case cfg.primaryIsPlugin() && len(p.PluginRefs) > 0: + p.PrimaryVersion = p.PluginRefs[cfg.PluginKeys[0]].GitRef + case len(cfg.GoModules) > 0: + p.PrimaryVersion = p.ModuleVersions[cfg.GoModules[0]] + } + p.PrimarySHA = VersionSHA(p.PrimaryVersion) + return p +} + +// shortSHA trims a SHA for display. +func shortSHA(sha string) string { + if len(sha) > 12 { + return sha[:12] + } + return sha +} diff --git a/tools/ccip/ccip-release-changelog/internal/changelog/deps_test.go b/tools/ccip/ccip-release-changelog/internal/changelog/deps_test.go new file mode 100644 index 00000000000..e9ca7eb7f31 --- /dev/null +++ b/tools/ccip/ccip-release-changelog/internal/changelog/deps_test.go @@ -0,0 +1,151 @@ +package changelog + +import ( + "testing" +) + +func TestVersionSHA(t *testing.T) { + t.Parallel() + + cases := []struct { + version string + want string + }{ + {"v0.0.0-20260714122420-7b2200a59a79", "7b2200a59a79"}, + {"v0.1.1-solana.0.20260625091148-e5618f5682ee", "e5618f5682ee"}, + {"v0.3.4-0.20260715161014-611d8ac32364", "611d8ac32364"}, + {"v1.0.5-0.20260629213843-c52e07523035", "c52e07523035"}, + {"v1.3.1-0.20260605202330-b5a89c32fdc1", "b5a89c32fdc1"}, + {"a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2", "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2"}, + {"v2.55.0", ""}, // clean release tag: no SHA + {"v0.1.1-solana.0", ""}, // prerelease tag: no SHA + {"", ""}, + } + for _, c := range cases { + if got := VersionSHA(c.version); got != c.want { + t.Errorf("VersionSHA(%q) = %q, want %q", c.version, got, c.want) + } + } +} + +const testGoMod = `module github.com/smartcontractkit/chainlink/v2 + +go 1.26.4 + +require ( + github.com/smartcontractkit/chainlink-aptos/codec v0.0.0-20260714122420-7b2200a59a79 + github.com/smartcontractkit/chainlink-ccip v0.1.1-solana.0.20260625091148-e5618f5682ee + github.com/smartcontractkit/chainlink-ccip/chains/evm v0.0.0-20260624154507-ea7ff77a0ddb + github.com/smartcontractkit/chainlink-ccip/chains/solana v0.0.0-20260415165642-49f23e4d76cc + github.com/smartcontractkit/chainlink-ccip/chains/solana/gobindings v0.0.0-20260415165642-49f23e4d76cc + github.com/smartcontractkit/chainlink-evm v0.3.4-0.20260715161014-611d8ac32364 + github.com/smartcontractkit/chainlink-sui/codec v0.0.0-20260714120433-7667cad5ff5c + github.com/smartcontractkit/chainlink-ton v1.0.5-0.20260629213843-c52e07523035 + github.com/smartcontractkit/chainlink-common v0.0.0-20260101000000-aaaaaaaaaaaa +) +` + +func TestParseGoMod(t *testing.T) { + t.Parallel() + + mods, err := ParseGoMod([]byte(testGoMod)) + if err != nil { + t.Fatal(err) + } + // Tracked modules extracted. + if got := mods["github.com/smartcontractkit/chainlink-ccip"]; got != "v0.1.1-solana.0.20260625091148-e5618f5682ee" { + t.Errorf("chainlink-ccip version = %q", got) + } + if got := mods["github.com/smartcontractkit/chainlink-ton"]; got != "v1.0.5-0.20260629213843-c52e07523035" { + t.Errorf("chainlink-ton version = %q", got) + } + // Untracked modules ignored. + if _, ok := mods["github.com/smartcontractkit/chainlink-common"]; ok { + t.Error("chainlink-common should not be tracked") + } + // solana main module is not in root go.mod. + if _, ok := mods["github.com/smartcontractkit/chainlink-solana"]; ok { + t.Error("chainlink-solana should not be present") + } + if len(mods) != 8 { + t.Errorf("expected 8 tracked modules, got %d: %v", len(mods), mods) + } +} + +const testPluginsYAML = `defaults: + goflags: "-ldflags=-s" + +plugins: + aptos: + - moduleURI: "github.com/smartcontractkit/chainlink-aptos" + gitRef: "v0.0.0-20260708114855-e953eeb028a7" + installPath: "./cmd/chainlink-aptos" + sui: + - moduleURI: "github.com/smartcontractkit/chainlink-sui" + # Must track the gRPC-migrated chainlink-sui used in go.mod + gitRef: "v0.0.0-20260707125635-abec997b6eae" + installPath: "./relayer/cmd/chainlink-sui" + solana: + - moduleURI: "github.com/smartcontractkit/chainlink-solana" + gitRef: "v1.3.1-0.20260605202330-b5a89c32fdc1" + installPath: "./pkg/solana/cmd/chainlink-solana" + ton: + - moduleURI: "github.com/smartcontractkit/chainlink-ton" + gitRef: "v1.0.5-0.20260629213843-c52e07523035" + installPath: "./cmd/chainlink-ton" + evm: + - moduleURI: "github.com/smartcontractkit/chainlink-evm" + gitRef: "v0.3.4-0.20260715161014-611d8ac32364" + installPath: "./pkg/cmd/chainlink-evm" + starknet: + - moduleURI: "github.com/smartcontractkit/chainlink-starknet/relayer" + gitRef: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + installPath: "./cmd/chainlink-starknet" +` + +func TestParsePluginsYAML(t *testing.T) { + t.Parallel() + + plugins, err := ParsePluginsYAML([]byte(testPluginsYAML)) + if err != nil { + t.Fatal(err) + } + if got := plugins["aptos"].GitRef; got != "v0.0.0-20260708114855-e953eeb028a7" { + t.Errorf("aptos gitRef = %q", got) + } + if got := plugins["ton"].ModuleURI; got != "github.com/smartcontractkit/chainlink-ton" { + t.Errorf("ton moduleURI = %q", got) + } + if _, ok := plugins["starknet"]; ok { + t.Error("starknet should not be tracked") + } + if len(plugins) != 5 { + t.Errorf("expected 5 tracked plugins, got %d: %v", len(plugins), plugins) + } +} + +func TestPinFor_PrimarySelection(t *testing.T) { + t.Parallel() + + mods, _ := ParseGoMod([]byte(testGoMod)) + plugins, _ := ParsePluginsYAML([]byte(testPluginsYAML)) + snap := DepSnapshot{Ref: "v1", SHA: "sha", Modules: mods, Plugins: plugins} + + // ccip: no plugin -> primary go.mod module. + ccip := pinFor(TrackedRepos[0], snap) + if ccip.PrimarySHA != "e5618f5682ee" { + t.Errorf("ccip primary SHA = %q", ccip.PrimarySHA) + } + + // aptos: plugin gitRef is primary, not the codec module. + aptos := pinFor(TrackedRepos[1], snap) + if aptos.PrimarySHA != "e953eeb028a7" { + t.Errorf("aptos primary SHA = %q, want plugin gitRef SHA", aptos.PrimarySHA) + } + + // ton: plugin primary even though go.mod module exists (same SHA here). + ton := pinFor(TrackedRepos[4], snap) + if ton.PrimarySHA != "c52e07523035" { + t.Errorf("ton primary SHA = %q", ton.PrimarySHA) + } +} diff --git a/tools/ccip/ccip-release-changelog/internal/changelog/facade.go b/tools/ccip/ccip-release-changelog/internal/changelog/facade.go new file mode 100644 index 00000000000..52b67f28f25 --- /dev/null +++ b/tools/ccip/ccip-release-changelog/internal/changelog/facade.go @@ -0,0 +1,46 @@ +package changelog + +import ( + "context" + "fmt" + "os" + "regexp" + "strings" +) + +// Generate runs the full analysis for two refs of the core repo checkout at +// repoDir. +func Generate(ctx context.Context, repoDir, oldRef, newRef string) (*Report, error) { + g := gitRunner{dir: repoDir} + gh := newGHClient(ctx) + return Analyze(ctx, g, gh, oldRef, newRef) +} + +// PostSummary posts a message into a Slack thread. +func PostSummary(ctx context.Context, token string, thread SlackThread, text string) error { + return newSlackClient(token).PostMessage(ctx, thread, text) +} + +// UploadReport uploads the full markdown report as a file in a Slack thread. +// Requires the files:write scope on the bot token; callers should treat a +// failure here as non-fatal if the summary was already delivered. +func UploadReport(ctx context.Context, token string, thread SlackThread, filename, title, markdown string) error { + return newSlackClient(token).UploadFile(ctx, thread, filename, title, []byte(markdown), "") +} + +// ActionsRunURL returns the URL of the current GitHub Actions run, or "" when +// not running in CI. +func ActionsRunURL() string { + server, repo, runID := os.Getenv("GITHUB_SERVER_URL"), os.Getenv("GITHUB_REPOSITORY"), os.Getenv("GITHUB_RUN_ID") + if server == "" || repo == "" || runID == "" { + return "" + } + return fmt.Sprintf("%s/%s/actions/runs/%s", server, repo, runID) +} + +var filenameUnsafe = regexp.MustCompile(`[^a-zA-Z0-9._-]+`) + +// SanitizeForFilename makes a git ref safe for use in a filename. +func SanitizeForFilename(ref string) string { + return strings.Trim(filenameUnsafe.ReplaceAllString(ref, "-"), "-") +} diff --git a/tools/ccip/ccip-release-changelog/internal/changelog/github.go b/tools/ccip/ccip-release-changelog/internal/changelog/github.go new file mode 100644 index 00000000000..40033df9a86 --- /dev/null +++ b/tools/ccip/ccip-release-changelog/internal/changelog/github.go @@ -0,0 +1,153 @@ +package changelog + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "os" + "os/exec" + "regexp" + "strconv" + "strings" + "time" +) + +// CommitEntry is one changelog line's worth of commit data. +type CommitEntry struct { + SHA string + Title string + PR int // extracted from a squash-merge "(#1234)" suffix; 0 if none + Author string // GitHub @login when known, else the git author name +} + +// prSuffix extracts a PR number from a trailing "(#1234)" in a commit title. +var prSuffix = regexp.MustCompile(`\(#[0-9]+\)\s*$`) + +var prNumber = regexp.MustCompile(`\(#([0-9]+)\)\s*$`) + +// parseTitle splits a commit message's first line into a cleaned title and an +// optional PR number. +func parseTitle(message string) (title string, pr int) { + title = firstLine(message) + if m := prNumber.FindStringSubmatch(title); m != nil { + pr, _ = strconv.Atoi(m[1]) // digits guaranteed by the regex + title = prSuffix.ReplaceAllString(title, "") + } + return strings.TrimSpace(title), pr +} + +func firstLine(s string) string { + line, _, _ := strings.Cut(s, "\n") + return line +} + +// compareResult holds the relevant parts of the GitHub compare API response. +type compareResult struct { + Status string // "ahead", "behind", "diverged", "identical" + AheadBy int + BehindBy int + Commits []CommitEntry + TotalCommits int // commits field of the API response (may exceed len(Commits) if capped) +} + +// ghClient calls the GitHub REST API. +type ghClient struct { + httpClient *http.Client + token string + baseURL string // overridable for tests +} + +// newGHClient builds a client using GITHUB_TOKEN/GH_TOKEN, falling back to +// `gh auth token`, and finally to unauthenticated access (public repos only, +// low rate limits). +func newGHClient(ctx context.Context) *ghClient { + token := os.Getenv("GITHUB_TOKEN") + if token == "" { + token = os.Getenv("GH_TOKEN") + } + if token == "" { + if out, err := exec.CommandContext(ctx, "gh", "auth", "token").Output(); err == nil { + token = strings.TrimSpace(string(out)) + } + } + return &ghClient{ + httpClient: &http.Client{Timeout: 30 * time.Second}, + token: token, + baseURL: "https://api.github.com", + } +} + +type compareResponse struct { + Status string `json:"status"` + AheadBy int `json:"ahead_by"` + BehindBy int `json:"behind_by"` + TotalCommits int `json:"total_commits"` + Commits []struct { + SHA string `json:"sha"` + Commit struct { + Message string `json:"message"` + Author struct { + Name string `json:"name"` + } `json:"author"` + } `json:"commit"` + Author *struct { + Login string `json:"login"` + } `json:"author"` + } `json:"commits"` +} + +// Compare runs a GitHub compare of base...head on owner/repo. +func (c *ghClient) Compare(ctx context.Context, owner, repo, base, head string) (compareResult, error) { + url := fmt.Sprintf("%s/repos/%s/%s/compare/%s...%s", c.baseURL, owner, repo, base, head) + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return compareResult{}, err + } + req.Header.Set("Accept", "application/vnd.github+json") + if c.token != "" { + req.Header.Set("Authorization", "Bearer "+c.token) + } + resp, err := c.httpClient.Do(req) + if err != nil { + return compareResult{}, fmt.Errorf("compare %s/%s %s...%s: %w", owner, repo, base, head, err) + } + defer resp.Body.Close() + body, err := io.ReadAll(resp.Body) + if err != nil { + return compareResult{}, err + } + if resp.StatusCode != http.StatusOK { + return compareResult{}, fmt.Errorf("compare %s/%s %s...%s: HTTP %d: %s", + owner, repo, base, head, resp.StatusCode, truncate(string(body), 200)) + } + var cr compareResponse + if err := json.Unmarshal(body, &cr); err != nil { + return compareResult{}, fmt.Errorf("decoding compare response: %w", err) + } + res := compareResult{ + Status: cr.Status, + AheadBy: cr.AheadBy, + BehindBy: cr.BehindBy, + TotalCommits: cr.TotalCommits, + } + for _, cmt := range cr.Commits { + title, pr := parseTitle(cmt.Commit.Message) + author := cmt.Commit.Author.Name + if cmt.Author != nil && cmt.Author.Login != "" { + author = "@" + cmt.Author.Login + } + res.Commits = append(res.Commits, CommitEntry{ + SHA: cmt.SHA, Title: title, PR: pr, Author: author, + }) + } + return res, nil +} + +func truncate(s string, n int) string { + if len(s) > n { + return s[:n] + "..." + } + return s +} diff --git a/tools/ccip/ccip-release-changelog/internal/changelog/github_test.go b/tools/ccip/ccip-release-changelog/internal/changelog/github_test.go new file mode 100644 index 00000000000..a7cc19518a0 --- /dev/null +++ b/tools/ccip/ccip-release-changelog/internal/changelog/github_test.go @@ -0,0 +1,107 @@ +package changelog + +import ( + "context" + "fmt" + "net/http" + "net/http/httptest" + "testing" +) + +func TestParseTitle(t *testing.T) { + t.Parallel() + + cases := []struct { + message string + wantTitle string + wantPR int + }{ + {"feat: add lane (#1234)", "feat: add lane", 1234}, + {"feat: add lane (#1234)\n\nSome body text\n", "feat: add lane", 1234}, + {"direct push commit", "direct push commit", 0}, + {"fix: parens (not a PR) in middle (#42)", "fix: parens (not a PR) in middle", 42}, + } + for _, c := range cases { + title, pr := parseTitle(c.message) + if title != c.wantTitle || pr != c.wantPR { + t.Errorf("parseTitle(%q) = (%q, %d), want (%q, %d)", c.message, title, pr, c.wantTitle, c.wantPR) + } + } +} + +const compareFixture = `{ + "status": "ahead", + "ahead_by": 2, + "behind_by": 0, + "total_commits": 2, + "commits": [ + { + "sha": "1111111111111111111111111111111111111111", + "commit": { + "message": "feat: add lane (#1234)", + "author": { "name": "Octo Cat" } + }, + "author": { "login": "octocat" } + }, + { + "sha": "2222222222222222222222222222222222222222", + "commit": { + "message": "direct push commit", + "author": { "name": "No Account" } + }, + "author": null + } + ] +}` + +func TestCompare(t *testing.T) { + t.Parallel() + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + wantPath := "/repos/smartcontractkit/chainlink-ccip/compare/aaa...bbb" + if r.URL.Path != wantPath { + t.Errorf("unexpected path: %s", r.URL.Path) + } + if r.Header.Get("Authorization") != "Bearer test-token" { + t.Errorf("missing/incorrect auth header") + } + fmt.Fprint(w, compareFixture) + })) + defer srv.Close() + + c := &ghClient{httpClient: srv.Client(), token: "test-token", baseURL: srv.URL} + res, err := c.Compare(context.Background(), "smartcontractkit", "chainlink-ccip", "aaa", "bbb") + if err != nil { + t.Fatal(err) + } + if res.Status != "ahead" || res.AheadBy != 2 { + t.Errorf("unexpected result: %+v", res) + } + if len(res.Commits) != 2 { + t.Fatalf("expected 2 commits, got %d", len(res.Commits)) + } + first := res.Commits[0] + if first.Title != "feat: add lane" || first.PR != 1234 || first.Author != "@octocat" { + t.Errorf("unexpected first commit: %+v", first) + } + second := res.Commits[1] + if second.PR != 0 || second.Author != "No Account" { + t.Errorf("unexpected second commit: %+v", second) + } +} + +func TestCompareHTTPError(t *testing.T) { + t.Parallel() + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNotFound) + fmt.Fprint(w, `{"message":"Not Found"}`) + })) + defer srv.Close() + + c := &ghClient{httpClient: srv.Client(), token: "", baseURL: srv.URL} + _, err := c.Compare(context.Background(), "smartcontractkit", "chainlink-ccip", "aaa", "bbb") + if err == nil { + t.Fatal("expected error") + } +} diff --git a/tools/ccip/ccip-release-changelog/internal/changelog/refs.go b/tools/ccip/ccip-release-changelog/internal/changelog/refs.go new file mode 100644 index 00000000000..edbe4d78265 --- /dev/null +++ b/tools/ccip/ccip-release-changelog/internal/changelog/refs.go @@ -0,0 +1,120 @@ +package changelog + +import ( + "bytes" + "context" + "fmt" + "os/exec" + "strings" +) + +// gitRunner executes git commands in the given repository directory. +type gitRunner struct { + dir string +} + +func (g gitRunner) run(ctx context.Context, args ...string) (string, error) { + cmd := exec.CommandContext(ctx, "git", args...) + cmd.Dir = g.dir + var out, errBuf bytes.Buffer + cmd.Stdout = &out + cmd.Stderr = &errBuf + if err := cmd.Run(); err != nil { + return "", fmt.Errorf("git %s: %w: %s", strings.Join(args, " "), err, strings.TrimSpace(errBuf.String())) + } + return out.String(), nil +} + +// ResolveRef resolves any git ref (SHA, tag, branch) to a commit SHA. +// If the ref does not resolve locally, it falls back to the remote-tracking +// branch origin/ (release branches often exist only remotely). +func (g gitRunner) ResolveRef(ctx context.Context, ref string) (string, error) { + out, err := g.run(ctx, "rev-parse", "--verify", ref+"^{commit}") + if err == nil { + return strings.TrimSpace(out), nil + } + if !strings.HasPrefix(ref, "origin/") && !strings.HasPrefix(ref, "refs/") { + if out, ferr := g.run(ctx, "rev-parse", "--verify", "origin/"+ref+"^{commit}"); ferr == nil { + return strings.TrimSpace(out), nil + } + } + return "", fmt.Errorf("resolving ref %q: %w", ref, err) +} + +// FileAtRef returns the contents of path at the given ref. +func (g gitRunner) FileAtRef(ctx context.Context, ref, path string) ([]byte, error) { + out, err := g.run(ctx, "show", ref+":"+path) + if err != nil { + return nil, fmt.Errorf("reading %s at %s: %w", path, ref, err) + } + return []byte(out), nil +} + +// localCommit is one commit from the local git log. +type localCommit struct { + SHA string + AuthorName string + AuthorEmail string + Title string +} + +// LogRange lists commits in old..new (commits reachable from new but not old). +func (g gitRunner) LogRange(ctx context.Context, oldSHA, newSHA string) ([]localCommit, error) { + out, err := g.run(ctx, "log", "--format=%H%x00%an%x00%ae%x00%s", oldSHA+".."+newSHA) + if err != nil { + return nil, err + } + var commits []localCommit + for line := range strings.SplitSeq(strings.TrimSpace(out), "\n") { + if line == "" { + continue + } + parts := strings.SplitN(line, "\x00", 4) + if len(parts) != 4 { + continue + } + commits = append(commits, localCommit{ + SHA: parts[0], + AuthorName: parts[1], + AuthorEmail: parts[2], + Title: parts[3], + }) + } + return commits, nil +} + +// IsAncestor reports whether oldSHA is an ancestor of newSHA. +func (g gitRunner) IsAncestor(ctx context.Context, oldSHA, newSHA string) bool { + cmd := exec.CommandContext(ctx, "git", "merge-base", "--is-ancestor", oldSHA, newSHA) + cmd.Dir = g.dir + return cmd.Run() == nil +} + +// CommitFiles returns the file paths touched by a single commit. +// Merge commits are diffed against their first parent. +func (g gitRunner) CommitFiles(ctx context.Context, sha string) ([]string, error) { + out, err := g.run(ctx, "diff-tree", "--no-commit-id", "--name-only", "-r", sha) + if err != nil { + return nil, err + } + paths := splitLines(out) + if len(paths) == 0 { + // Possibly a merge commit: diff against first parent. + out, err = g.run(ctx, "diff-tree", "--first-parent", "-m", "--no-commit-id", "--name-only", "-r", sha) + if err != nil { + return nil, err + } + paths = splitLines(out) + } + return paths, nil +} + +func splitLines(s string) []string { + var out []string + for line := range strings.SplitSeq(strings.TrimSpace(s), "\n") { + if line != "" { + out = append(out, line) + } + } + return out +} diff --git a/tools/ccip/ccip-release-changelog/internal/changelog/refs_test.go b/tools/ccip/ccip-release-changelog/internal/changelog/refs_test.go new file mode 100644 index 00000000000..741c7f6bdc3 --- /dev/null +++ b/tools/ccip/ccip-release-changelog/internal/changelog/refs_test.go @@ -0,0 +1,87 @@ +package changelog + +import ( + "context" + "os" + "os/exec" + "path/filepath" + "testing" +) + +// gitCmd runs git in dir with a fixed identity, failing the test on error. +func gitCmd(t *testing.T, dir string, args ...string) { + t.Helper() + full := append([]string{"-c", "user.name=Test", "-c", "user.email=test@example.com", + "-c", "init.defaultBranch=main"}, args...) + cmd := exec.CommandContext(context.Background(), "git", full...) + cmd.Dir = dir + if out, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("git %v: %v: %s", args, err, out) + } +} + +// setupOriginOnlyBranch creates two repos: "remote" with a commit on branch +// release/9.99.0, and "local" which fetches it — so release/9.99.0 exists in +// local only as refs/remotes/origin/release/9.99.0, not as a local branch. +// Returns (localDir, remoteHEAD). +func setupOriginOnlyBranch(t *testing.T) (string, string) { + t.Helper() + base := t.TempDir() + remote := filepath.Join(base, "remote") + local := filepath.Join(base, "local") + + for _, dir := range []string{remote, local} { + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatal(err) + } + gitCmd(t, dir, "init") + } + if err := os.WriteFile(filepath.Join(remote, "go.mod"), []byte("module example.com/x\n"), 0o600); err != nil { + t.Fatal(err) + } + gitCmd(t, remote, "add", ".") + gitCmd(t, remote, "commit", "-m", "init") + gitCmd(t, remote, "branch", "release/9.99.0") + + gitCmd(t, local, "remote", "add", "origin", remote) + gitCmd(t, local, "fetch", "origin") + + // Sanity: no local branch named release/9.99.0. + cmd := exec.CommandContext(context.Background(), "git", "rev-parse", "--verify", "release/9.99.0^{commit}") + cmd.Dir = local + if err := cmd.Run(); err == nil { + t.Fatal("test setup invalid: local branch release/9.99.0 unexpectedly exists") + } + + out, err := exec.CommandContext(context.Background(), "git", "-C", remote, "rev-parse", "HEAD").Output() + if err != nil { + t.Fatal(err) + } + sha := string(out[:40]) + return local, sha +} + +func TestResolveRef_FallsBackToOrigin(t *testing.T) { + t.Parallel() + + local, wantSHA := setupOriginOnlyBranch(t) + g := gitRunner{dir: local} + + got, err := g.ResolveRef(context.Background(), "release/9.99.0") + if err != nil { + t.Fatalf("ResolveRef failed: %v", err) + } + if got != wantSHA { + t.Errorf("ResolveRef = %s, want %s", got, wantSHA) + } +} + +func TestResolveRef_UnknownRef(t *testing.T) { + t.Parallel() + + local, _ := setupOriginOnlyBranch(t) + g := gitRunner{dir: local} + if _, err := g.ResolveRef(context.Background(), "release/does-not-exist"); err == nil { + t.Error("expected error for unknown ref") + } +} diff --git a/tools/ccip/ccip-release-changelog/internal/changelog/report.go b/tools/ccip/ccip-release-changelog/internal/changelog/report.go new file mode 100644 index 00000000000..8c91c346d0e --- /dev/null +++ b/tools/ccip/ccip-release-changelog/internal/changelog/report.go @@ -0,0 +1,216 @@ +package changelog + +import ( + "fmt" + "strconv" + "strings" +) + +// displayName returns the human-facing name of a tracked repo. +func (r RepoReport) displayName() string { + if r.Config.Local && len(r.Config.IncludePaths) > 0 { + return fmt.Sprintf("%s (%s)", r.Config.Name, strings.Join(r.Config.IncludePaths, ", ")) + } + return r.Config.Name +} + +// commitURL returns the GitHub commit URL for an entry. +func (r RepoReport) commitURL(sha string) string { + return fmt.Sprintf("https://github.com/%s/%s/commit/%s", r.Config.Owner, r.Config.Name, sha) +} + +// prURL returns the GitHub PR URL for an entry. +func (r RepoReport) prURL(pr int) string { + return fmt.Sprintf("https://github.com/%s/%s/pull/%d", r.Config.Owner, r.Config.Name, pr) +} + +// formatEntry renders one commit line: +// "Title ([#1234](url)) ([`abc123`](url)) by @author" +func (r RepoReport) formatEntry(c CommitEntry) string { + var b strings.Builder + b.WriteString("- ") + if c.Title == "" { + b.WriteString("(no title)") + } else { + b.WriteString(c.Title) + } + if c.PR > 0 { + fmt.Fprintf(&b, " ([#%d](%s))", c.PR, r.prURL(c.PR)) + } + fmt.Fprintf(&b, " ([`%s`](%s))", shortSHA(c.SHA), r.commitURL(c.SHA)) + if c.Author != "" { + fmt.Fprintf(&b, " by %s", c.Author) + } + return b.String() +} + +// commitCountLine summarizes the commit count for a repo section header. +func (r RepoReport) commitCountLine() string { + filtered := len(r.Config.IncludePaths) > 0 || len(r.Config.ExcludePaths) > 0 + switch { + case r.Err != "": + return "⚠️ compare failed" + case r.Status == "identical": + return "no changes" + case r.Status == "behind": + return "⚠️ rolled back (see flags)" + case r.Status == "diverged": + return "⚠️ diverged history (see flags)" + case filtered && r.TotalInRange > len(r.Commits): + return fmt.Sprintf("%s touching tracked paths (%d total in range)", pluralize(len(r.Commits)), r.TotalInRange) + default: + return pluralize(len(r.Commits)) + } +} + +// pluralize renders "1 commit" / "N commits". +func pluralize(n int) string { + if n == 1 { + return "1 commit" + } + return fmt.Sprintf("%d commits", n) +} + +// RenderMarkdown produces the full audit document. +func RenderMarkdown(rep *Report) string { + var b strings.Builder + b.WriteString("# CCIP Release Changelog\n\n") + fmt.Fprintf(&b, "- **Old**: `%s` (`%s`)\n", rep.Old.Ref, rep.Old.SHA) + fmt.Fprintf(&b, "- **New**: `%s` (`%s`)\n", rep.New.Ref, rep.New.SHA) + fmt.Fprintf(&b, "- **Generated**: %s\n", rep.Generated.Format("2006-01-02 15:04 UTC")) + fmt.Fprintf(&b, "- **Core changelog**: [CHANGELOG.md at %s](https://github.com/smartcontractkit/chainlink/blob/%s/CHANGELOG.md)\n\n", + rep.New.Ref, rep.New.SHA) + + // Flags + b.WriteString("## ⚠️ Flags\n\n") + if len(rep.Flags) == 0 { + b.WriteString("No risk flags for this range. ✅\n\n") + } else { + for _, f := range rep.Flags { + fmt.Fprintf(&b, "- %s\n", f) + } + b.WriteString("\n") + } + + // go.mod changes + b.WriteString("## go.mod changes (CCIP modules)\n\n") + gomodAny := false + for _, rr := range rep.Repos { + if len(rr.Config.GoModules) == 0 { + continue + } + gomodAny = true + fmt.Fprintf(&b, "### %s\n\n", rr.Config.Name) + for _, m := range rr.Config.GoModules { + oldV, oldOK := rep.Old.Modules[m] + newV, newOK := rep.New.Modules[m] + fmt.Fprintf(&b, "- `%s`: %s\n", shortModule(m), versionTransition(oldV, oldOK, newV, newOK)) + } + b.WriteString("\n") + } + if !gomodAny { + b.WriteString("No tracked go.mod modules.\n\n") + } + + // plugins.public.yaml changes + b.WriteString("## plugins.public.yaml changes (CCIP plugins)\n\n") + pluginsAny := false + for _, rr := range rep.Repos { + for _, k := range rr.Config.PluginKeys { + pluginsAny = true + oldP, oldOK := rep.Old.Plugins[k] + newP, newOK := rep.New.Plugins[k] + var oldV, newV string + if oldOK { + oldV = oldP.GitRef + } + if newOK { + newV = newP.GitRef + } + fmt.Fprintf(&b, "- **%s** (`%s`): %s\n", k, rr.Config.Name, versionTransition(oldV, oldOK, newV, newOK)) + } + } + if !pluginsAny { + b.WriteString("No tracked plugins.\n") + } + b.WriteString("\n") + + // Commit changelogs + b.WriteString("## Commit changelogs\n\n") + for _, rr := range rep.Repos { + fmt.Fprintf(&b, "### %s — %s\n\n", rr.displayName(), rr.commitCountLine()) + if rr.Err != "" { + fmt.Fprintf(&b, "⚠️ %s\n\n", rr.Err) + continue + } + for _, note := range rr.Notes { + fmt.Fprintf(&b, "> %s\n", note) + } + if len(rr.Notes) > 0 { + b.WriteString("\n") + } + if rr.Truncated { + fmt.Fprintf(&b, "> ⚠️ commit list truncated by GitHub API (showing %d of %d)\n\n", len(rr.Commits), rr.TotalInRange) + } + if rr.Status != "identical" { + for _, c := range rr.Commits { + b.WriteString(rr.formatEntry(c) + "\n") + } + if len(rr.Commits) > 0 { + b.WriteString("\n") + } + } + } + return b.String() +} + +// RenderSlackSummary produces the compact Slack message (mrkdwn format). +func RenderSlackSummary(rep *Report) string { + var b strings.Builder + fmt.Fprintf(&b, "*CCIP Release Changelog* `%s` → `%s`\n", rep.Old.Ref, rep.New.Ref) + fmt.Fprintf(&b, " · ", shortSHA(rep.Old.SHA), shortSHA(rep.New.SHA)) + fmt.Fprintf(&b, "\n\n", rep.New.SHA) + + if len(rep.Flags) == 0 { + b.WriteString(":white_check_mark: *No risk flags for this range.*\n\n") + } else { + fmt.Fprintf(&b, ":warning: *Flags (%d)*\n", len(rep.Flags)) + for _, f := range rep.Flags { + // Slack mrkdwn uses *bold*, not **bold**. + fmt.Fprintf(&b, "• %s\n", strings.ReplaceAll(f, "**", "*")) + } + b.WriteString("\n") + } + + b.WriteString("*Commits per repo*\n") + var parts []string + for _, rr := range rep.Repos { + switch { + case rr.Err != "": + parts = append(parts, rr.Config.Name+": ⚠️ error") + case rr.Status == "identical": + parts = append(parts, rr.Config.Name+": no change") + default: + parts = append(parts, rr.Config.Name+": "+strconv.Itoa(len(rr.Commits))) + } + } + b.WriteString(strings.Join(parts, " · ")) + b.WriteString("\n\nFull report attached below :point_down:") + return b.String() +} + +// versionTransition renders an old → new version transition. +func versionTransition(oldV string, oldOK bool, newV string, newOK bool) string { + switch { + case !oldOK && !newOK: + return "not present at either ref" + case !oldOK: + return fmt.Sprintf("_(added)_ → `%s`", newV) + case !newOK: + return fmt.Sprintf("`%s` → _(removed)_", oldV) + case oldV == newV: + return fmt.Sprintf("no change (`%s`)", newV) + default: + return fmt.Sprintf("`%s` → `%s`", oldV, newV) + } +} diff --git a/tools/ccip/ccip-release-changelog/internal/changelog/report_test.go b/tools/ccip/ccip-release-changelog/internal/changelog/report_test.go new file mode 100644 index 00000000000..ad4dcbbfa08 --- /dev/null +++ b/tools/ccip/ccip-release-changelog/internal/changelog/report_test.go @@ -0,0 +1,155 @@ +package changelog + +import ( + "os" + "path/filepath" + "testing" + "time" +) + +// syntheticReport builds a representative report covering: plugin change, +// drift, rollback, keyword hits, divergence note, filtered repo, errors. +func syntheticReport() *Report { + oldSnap := DepSnapshot{ + Ref: "v2.55.0", SHA: "oldoldoldoldoldoldoldoldoldoldoldold0000", + Modules: map[string]string{ + "github.com/smartcontractkit/chainlink-ccip": "v0.1.1-solana.0.20260101000000-aaaaaaaaaaaa", + "github.com/smartcontractkit/chainlink-ccip/chains/evm": "v0.0.0-20260101000000-bbbbbbbbbbbb", + "github.com/smartcontractkit/chainlink-ton": "v1.0.5-0.20260101000000-111111111111", + "github.com/smartcontractkit/chainlink-evm": "v0.3.4-0.20260101000000-222222222222", + }, + Plugins: map[string]PluginPin{ + "ton": {GitRef: "v1.0.5-0.20260101000000-111111111111", ModuleURI: "github.com/smartcontractkit/chainlink-ton"}, + "evm": {GitRef: "v0.3.4-0.20260101000000-222222222222", ModuleURI: "github.com/smartcontractkit/chainlink-evm"}, + "solana": {GitRef: "v1.3.1-0.20260101000000-333333333333", ModuleURI: "github.com/smartcontractkit/chainlink-solana"}, + }, + } + newSnap := DepSnapshot{ + Ref: "v2.56.0", SHA: "newnewnewnewnewnewnewnewnewnewnewnew0000", + Modules: map[string]string{ + "github.com/smartcontractkit/chainlink-ccip": "v0.1.1-solana.0.20260202000000-cccccccccccc", + "github.com/smartcontractkit/chainlink-ccip/chains/evm": "v0.0.0-20260202000000-dddddddddddd", + "github.com/smartcontractkit/chainlink-ton": "v1.0.5-0.20260202000000-444444444444", + "github.com/smartcontractkit/chainlink-evm": "v0.3.4-0.20260101000000-222222222222", + }, + Plugins: map[string]PluginPin{ + "ton": {GitRef: "v1.0.5-0.20260202000000-555555555555", ModuleURI: "github.com/smartcontractkit/chainlink-ton"}, + "evm": {GitRef: "v0.3.4-0.20260101000000-222222222222", ModuleURI: "github.com/smartcontractkit/chainlink-evm"}, + "solana": {GitRef: "v1.3.1-0.20260101000000-333333333333", ModuleURI: "github.com/smartcontractkit/chainlink-solana"}, + }, + } + + ccipCfg := TrackedRepos[0] + tonCfg := TrackedRepos[4] + evmCfg := TrackedRepos[5] + solCfg := TrackedRepos[3] + coreCfg := TrackedRepos[6] + + rep := &Report{ + Old: oldSnap, New: newSnap, + Generated: time.Date(2026, 7, 30, 12, 0, 0, 0, time.UTC), + } + + ccip := RepoReport{ + Config: ccipCfg, + Old: repoPin{PrimaryVersion: oldSnap.Modules[ccipCfg.GoModules[0]], PrimarySHA: "aaaaaaaaaaaa"}, + New: repoPin{PrimaryVersion: newSnap.Modules[ccipCfg.GoModules[0]], PrimarySHA: "cccccccccccc"}, + Status: "ahead", TotalInRange: 2, + Commits: []CommitEntry{ + {SHA: "deadbeef0000", Title: "feat: add fast lane config", PR: 1234, Author: "@octocat"}, + {SHA: "feedface0000", Title: "chore: bump deps", PR: 1235, Author: "Jane Doe"}, + }, + } + ccip.KeywordHits = []CommitEntry{ccip.Commits[0]} // "config" in title + ccip.Notes = []string{"divergent pins (new ref): chainlink-ccip at `cccccccccccc`; chainlink-ccip/chains/evm at `dddddddddddd`"} + + ton := RepoReport{ + Config: tonCfg, + Old: repoPin{PrimaryVersion: "v1.0.5-0.20260101000000-111111111111", PrimarySHA: "111111111111"}, + New: repoPin{PrimaryVersion: "v1.0.5-0.20260202000000-555555555555", PrimarySHA: "555555555555"}, + Status: "ahead", TotalInRange: 1, + Commits: []CommitEntry{ + {SHA: "cafe0000cafe", Title: "hotfix for gas estimator", PR: 99, Author: "@tondev"}, + }, + } + ton.KeywordHits = ton.Commits + + evm := RepoReport{ + Config: evmCfg, Status: "identical", + Old: repoPin{PrimaryVersion: "v0.3.4-0.20260101000000-222222222222", PrimarySHA: "222222222222"}, + New: repoPin{PrimaryVersion: "v0.3.4-0.20260101000000-222222222222", PrimarySHA: "222222222222"}, + } + + sol := RepoReport{ + Config: solCfg, Status: "behind", + Old: repoPin{PrimaryVersion: "v1.3.1-0.20260101000000-333333333333", PrimarySHA: "333333333333"}, + New: repoPin{PrimaryVersion: "v1.3.1-0.20260101000000-333333333333", PrimarySHA: "333333333333"}, + Err: "", + } + sol.Status = "behind" + sol.Commits = nil + + core := RepoReport{ + Config: coreCfg, Status: "ahead", + TotalInRange: 87, + Commits: []CommitEntry{ + {SHA: "010101010101", Title: "feat(ccip): new capability", PR: 2000, Author: "@coredev"}, + }, + } + + broken := RepoReport{ + Config: TrackedRepos[1], // aptos + Err: "compare smartcontractkit/chainlink-aptos abc...def: HTTP 404: Not Found", + } + + rep.Repos = []RepoReport{ccip, broken, RepoReport{Config: TrackedRepos[2], Status: "identical"}, sol, ton, evm, core} + + for _, rr := range rep.Repos { + rep.Flags = append(rep.Flags, repoFlags(rr, oldSnap, newSnap)...) + } + return rep +} + +func TestRenderMarkdown_Golden(t *testing.T) { + t.Parallel() + + got := RenderMarkdown(syntheticReport()) + goldenPath := filepath.Join("testdata", "report.golden.md") + if os.Getenv("UPDATE_GOLDEN") == "1" { + if err := os.MkdirAll("testdata", 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(goldenPath, []byte(got), 0o600); err != nil { + t.Fatal(err) + } + } + want, err := os.ReadFile(goldenPath) + if err != nil { + t.Fatalf("reading golden file (run with UPDATE_GOLDEN=1 to create): %v", err) + } + if got != string(want) { + t.Errorf("markdown mismatch.\n--- got ---\n%s\n--- want ---\n%s", got, want) + } +} + +func TestRenderSlackSummary_Golden(t *testing.T) { + t.Parallel() + + got := RenderSlackSummary(syntheticReport()) + goldenPath := filepath.Join("testdata", "slack-summary.golden.txt") + if os.Getenv("UPDATE_GOLDEN") == "1" { + if err := os.MkdirAll("testdata", 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(goldenPath, []byte(got), 0o600); err != nil { + t.Fatal(err) + } + } + want, err := os.ReadFile(goldenPath) + if err != nil { + t.Fatalf("reading golden file (run with UPDATE_GOLDEN=1 to create): %v", err) + } + if got != string(want) { + t.Errorf("slack summary mismatch.\n--- got ---\n%s\n--- want ---\n%s", got, want) + } +} diff --git a/tools/ccip/ccip-release-changelog/internal/changelog/slack.go b/tools/ccip/ccip-release-changelog/internal/changelog/slack.go new file mode 100644 index 00000000000..766390a926c --- /dev/null +++ b/tools/ccip/ccip-release-changelog/internal/changelog/slack.go @@ -0,0 +1,157 @@ +package changelog + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "regexp" + "strconv" + "strings" + "time" +) + +// SlackThread identifies a Slack thread to post into. +type SlackThread struct { + Channel string + ThreadTS string +} + +// threadURLPattern parses Slack thread/message links like: +// https://workspace.slack.com/archives/C0123ABCD/p16900000001234567 +var threadURLPattern = regexp.MustCompile(`^https://[a-z0-9-]+\.slack\.com/archives/([A-Z0-9]+)/p(\d{16})\b`) + +// ParseSlackThreadURL extracts channel ID and thread timestamp from a Slack +// thread link. +func ParseSlackThreadURL(raw string) (SlackThread, error) { + m := threadURLPattern.FindStringSubmatch(strings.TrimSpace(raw)) + if m == nil { + return SlackThread{}, fmt.Errorf("not a Slack thread URL: %q (expected https://.slack.com/archives//p)", raw) + } + digits := m[2] + ts := digits[:len(digits)-6] + "." + digits[len(digits)-6:] + return SlackThread{Channel: m[1], ThreadTS: ts}, nil +} + +// slackClient posts messages and files to Slack. +type slackClient struct { + httpClient *http.Client + token string + apiBase string // overridable for tests +} + +func newSlackClient(token string) *slackClient { + return &slackClient{ + httpClient: &http.Client{Timeout: 60 * time.Second}, + token: token, + apiBase: "https://slack.com/api", + } +} + +type slackAPIResponse struct { + OK bool `json:"ok"` + Error string `json:"error"` +} + +// doJSON performs a Slack Web API call with a JSON body. +func (s *slackClient) doJSON(ctx context.Context, method string, payload any) error { + body, err := json.Marshal(payload) + if err != nil { + return err + } + req, err := http.NewRequestWithContext(ctx, http.MethodPost, s.apiBase+"/"+method, bytes.NewReader(body)) + if err != nil { + return err + } + req.Header.Set("Authorization", "Bearer "+s.token) + req.Header.Set("Content-Type", "application/json; charset=utf-8") + resp, err := s.httpClient.Do(req) + if err != nil { + return fmt.Errorf("slack %s: %w", method, err) + } + defer resp.Body.Close() + respBody, _ := io.ReadAll(resp.Body) + var sr slackAPIResponse + if err := json.Unmarshal(respBody, &sr); err != nil { + return fmt.Errorf("slack %s: decoding response: %w (body: %s)", method, err, truncate(string(respBody), 200)) + } + if !sr.OK { + return fmt.Errorf("slack %s: API error: %s", method, sr.Error) + } + return nil +} + +// PostMessage posts text into a thread. +func (s *slackClient) PostMessage(ctx context.Context, t SlackThread, text string) error { + return s.doJSON(ctx, "chat.postMessage", map[string]any{ + "channel": t.Channel, + "thread_ts": t.ThreadTS, + "text": text, + "unfurl_links": false, + }) +} + +// UploadFile uploads content as a file into a thread using the external +// upload flow (files.getUploadURLExternal -> raw upload -> +// files.completeUploadExternal). +func (s *slackClient) UploadFile(ctx context.Context, t SlackThread, filename, title string, content []byte, comment string) error { + // Step 1: get an upload URL. + form := url.Values{ + "filename": {filename}, + "length": {strconv.Itoa(len(content))}, + } + req, err := http.NewRequestWithContext(ctx, http.MethodPost, s.apiBase+"/files.getUploadURLExternal", strings.NewReader(form.Encode())) + if err != nil { + return err + } + req.Header.Set("Authorization", "Bearer "+s.token) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + resp, err := s.httpClient.Do(req) + if err != nil { + return fmt.Errorf("files.getUploadURLExternal: %w", err) + } + defer resp.Body.Close() + body, _ := io.ReadAll(resp.Body) + var urlResp struct { + slackAPIResponse + UploadURL string `json:"upload_url"` + FileID string `json:"file_id"` + } + if unmarshalErr := json.Unmarshal(body, &urlResp); unmarshalErr != nil { + return fmt.Errorf("files.getUploadURLExternal: decoding response: %w", unmarshalErr) + } + if !urlResp.OK { + return fmt.Errorf("files.getUploadURLExternal: API error: %s", urlResp.Error) + } + + // Step 2: upload the raw bytes to the provided URL (no auth header). + upReq, err := http.NewRequestWithContext(ctx, http.MethodPost, urlResp.UploadURL, bytes.NewReader(content)) + if err != nil { + return err + } + upResp, err := s.httpClient.Do(upReq) + if err != nil { + return fmt.Errorf("uploading file bytes: %w", err) + } + defer upResp.Body.Close() + if upResp.StatusCode != http.StatusOK { + upBody, _ := io.ReadAll(upResp.Body) + return fmt.Errorf("uploading file bytes: HTTP %d: %s", upResp.StatusCode, truncate(string(upBody), 200)) + } + + // Step 3: complete the upload into the thread. + payload := map[string]any{ + "files": []map[string]string{ + {"id": urlResp.FileID, "title": title}, + }, + "channel_id": t.Channel, + "thread_ts": t.ThreadTS, + } + if comment != "" { + payload["initial_comment"] = comment + } + return s.doJSON(ctx, "files.completeUploadExternal", payload) +} diff --git a/tools/ccip/ccip-release-changelog/internal/changelog/slack_test.go b/tools/ccip/ccip-release-changelog/internal/changelog/slack_test.go new file mode 100644 index 00000000000..6bd371ed538 --- /dev/null +++ b/tools/ccip/ccip-release-changelog/internal/changelog/slack_test.go @@ -0,0 +1,133 @@ +package changelog + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +func TestParseSlackThreadURL(t *testing.T) { + t.Parallel() + + cases := []struct { + url string + wantChannel string + wantTS string + wantErr bool + }{ + {"https://myorg.slack.com/archives/C0123ABCD/p1690000000123456", "C0123ABCD", "1690000000.123456", false}, + {"https://my-org.slack.com/archives/G9876ZYXW/p1753967999000001?thread_ts=1753967999.000001&cid=G9876ZYXW", "G9876ZYXW", "1753967999.000001", false}, + {"https://myorg.slack.com/messages/C0123ABCD", "", "", true}, + {"not a url", "", "", true}, + {"", "", "", true}, + } + for _, c := range cases { + th, err := ParseSlackThreadURL(c.url) + if (err != nil) != c.wantErr { + t.Errorf("ParseSlackThreadURL(%q) err = %v, wantErr %v", c.url, err, c.wantErr) + continue + } + if !c.wantErr && (th.Channel != c.wantChannel || th.ThreadTS != c.wantTS) { + t.Errorf("ParseSlackThreadURL(%q) = (%s, %s), want (%s, %s)", + c.url, th.Channel, th.ThreadTS, c.wantChannel, c.wantTS) + } + } +} + +func TestSlackPostMessage(t *testing.T) { + t.Parallel() + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/chat.postMessage" { + t.Errorf("unexpected path %s", r.URL.Path) + } + if r.Header.Get("Authorization") != "Bearer xoxb-test" { + t.Error("missing auth") + } + var payload map[string]any + if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { + t.Errorf("decoding request: %v", err) + } + if payload["channel"] != "C123" || payload["thread_ts"] != "1.2" || payload["text"] != "hello" { + t.Errorf("unexpected payload: %v", payload) + } + fmt.Fprint(w, `{"ok":true}`) + })) + defer srv.Close() + + c := newSlackClient("xoxb-test") + c.apiBase = srv.URL + if err := c.PostMessage(context.Background(), SlackThread{Channel: "C123", ThreadTS: "1.2"}, "hello"); err != nil { + t.Fatal(err) + } +} + +func TestSlackUploadFile(t *testing.T) { + t.Parallel() + + var uploadReceived []byte + var completePayload map[string]any + var serverURL string + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/files.getUploadURLExternal": + fmt.Fprintf(w, `{"ok":true,"upload_url":%q,"file_id":"F123"}`, serverURL+"/upload") + case "/upload": + uploadReceived, _ = io.ReadAll(r.Body) + w.WriteHeader(http.StatusOK) + case "/files.completeUploadExternal": + if err := json.NewDecoder(r.Body).Decode(&completePayload); err != nil { + t.Errorf("decoding request: %v", err) + } + fmt.Fprint(w, `{"ok":true}`) + default: + t.Errorf("unexpected path %s", r.URL.Path) + } + })) + defer srv.Close() + serverURL = srv.URL + + c := newSlackClient("xoxb-test") + c.apiBase = srv.URL + content := []byte("# markdown report") + err := c.UploadFile(context.Background(), SlackThread{Channel: "C123", ThreadTS: "1.2"}, "report.md", "Report", content, "") + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(uploadReceived), "markdown report") { + t.Errorf("upload bytes not received: %q", uploadReceived) + } + if completePayload["channel_id"] != "C123" || completePayload["thread_ts"] != "1.2" { + t.Errorf("unexpected complete payload: %v", completePayload) + } + files, ok := completePayload["files"].([]any) + if !ok || len(files) != 1 { + t.Fatalf("unexpected files payload: %v", completePayload) + } + f0 := files[0].(map[string]any) + if f0["id"] != "F123" || f0["title"] != "Report" { + t.Errorf("unexpected file entry: %v", f0) + } +} + +func TestSlackAPIError(t *testing.T) { + t.Parallel() + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + fmt.Fprint(w, `{"ok":false,"error":"channel_not_found"}`) + })) + defer srv.Close() + + c := newSlackClient("xoxb-test") + c.apiBase = srv.URL + err := c.PostMessage(context.Background(), SlackThread{Channel: "C999", ThreadTS: "1.2"}, "hi") + if err == nil || !strings.Contains(err.Error(), "channel_not_found") { + t.Errorf("expected channel_not_found error, got %v", err) + } +} diff --git a/tools/ccip/ccip-release-changelog/internal/changelog/testdata/report.golden.md b/tools/ccip/ccip-release-changelog/internal/changelog/testdata/report.golden.md new file mode 100644 index 00000000000..75359d608e4 --- /dev/null +++ b/tools/ccip/ccip-release-changelog/internal/changelog/testdata/report.golden.md @@ -0,0 +1,76 @@ +# CCIP Release Changelog + +- **Old**: `v2.55.0` (`oldoldoldoldoldoldoldoldoldoldoldold0000`) +- **New**: `v2.56.0` (`newnewnewnewnewnewnewnewnewnewnewnew0000`) +- **Generated**: 2026-07-30 12:00 UTC +- **Core changelog**: [CHANGELOG.md at v2.56.0](https://github.com/smartcontractkit/chainlink/blob/newnewnewnewnewnewnewnewnewnewnewnew0000/CHANGELOG.md) + +## ⚠️ Flags + +- **chainlink-ccip**: keyword match — feat: add fast lane config ([`deadbeef0000`](https://github.com/smartcontractkit/chainlink-ccip/commit/deadbeef0000)) +- **chainlink-solana**: ROLLBACK — new pin `333333333333` is BEHIND old pin `333333333333` +- **chainlink-ton**: plugin `ton` gitRef changed `v1.0.5-0.20260101000000-111111111111` → `v1.0.5-0.20260202000000-555555555555` +- **chainlink-ton**: DRIFT at new ref — plugin `ton` pins `555555555555` but go.mod `chainlink-ton` pins `444444444444` +- **chainlink-ton**: keyword match — hotfix for gas estimator ([`cafe0000cafe`](https://github.com/smartcontractkit/chainlink-ton/commit/cafe0000cafe)) + +## go.mod changes (CCIP modules) + +### chainlink-ccip + +- `chainlink-ccip`: `v0.1.1-solana.0.20260101000000-aaaaaaaaaaaa` → `v0.1.1-solana.0.20260202000000-cccccccccccc` +- `chainlink-ccip/chains/evm`: `v0.0.0-20260101000000-bbbbbbbbbbbb` → `v0.0.0-20260202000000-dddddddddddd` +- `chainlink-ccip/chains/solana`: not present at either ref +- `chainlink-ccip/chains/solana/gobindings`: not present at either ref + +### chainlink-aptos + +- `chainlink-aptos/codec`: not present at either ref + +### chainlink-sui + +- `chainlink-sui/codec`: not present at either ref + +### chainlink-ton + +- `chainlink-ton`: `v1.0.5-0.20260101000000-111111111111` → `v1.0.5-0.20260202000000-444444444444` + +### chainlink-evm + +- `chainlink-evm`: no change (`v0.3.4-0.20260101000000-222222222222`) +- `chainlink-evm/gethwrappers`: not present at either ref + +## plugins.public.yaml changes (CCIP plugins) + +- **aptos** (`chainlink-aptos`): not present at either ref +- **sui** (`chainlink-sui`): not present at either ref +- **solana** (`chainlink-solana`): no change (`v1.3.1-0.20260101000000-333333333333`) +- **ton** (`chainlink-ton`): `v1.0.5-0.20260101000000-111111111111` → `v1.0.5-0.20260202000000-555555555555` +- **evm** (`chainlink-evm`): no change (`v0.3.4-0.20260101000000-222222222222`) + +## Commit changelogs + +### chainlink-ccip — 2 commits + +> divergent pins (new ref): chainlink-ccip at `cccccccccccc`; chainlink-ccip/chains/evm at `dddddddddddd` + +- feat: add fast lane config ([#1234](https://github.com/smartcontractkit/chainlink-ccip/pull/1234)) ([`deadbeef0000`](https://github.com/smartcontractkit/chainlink-ccip/commit/deadbeef0000)) by @octocat +- chore: bump deps ([#1235](https://github.com/smartcontractkit/chainlink-ccip/pull/1235)) ([`feedface0000`](https://github.com/smartcontractkit/chainlink-ccip/commit/feedface0000)) by Jane Doe + +### chainlink-aptos — ⚠️ compare failed + +⚠️ compare smartcontractkit/chainlink-aptos abc...def: HTTP 404: Not Found + +### chainlink-sui — no changes + +### chainlink-solana — ⚠️ rolled back (see flags) + +### chainlink-ton — 1 commit + +- hotfix for gas estimator ([#99](https://github.com/smartcontractkit/chainlink-ton/pull/99)) ([`cafe0000cafe`](https://github.com/smartcontractkit/chainlink-ton/commit/cafe0000cafe)) by @tondev + +### chainlink-evm — no changes + +### chainlink (core/capabilities/ccip/) — 1 commit touching tracked paths (87 total in range) + +- feat(ccip): new capability ([#2000](https://github.com/smartcontractkit/chainlink/pull/2000)) ([`010101010101`](https://github.com/smartcontractkit/chainlink/commit/010101010101)) by @coredev + diff --git a/tools/ccip/ccip-release-changelog/internal/changelog/testdata/slack-summary.golden.txt b/tools/ccip/ccip-release-changelog/internal/changelog/testdata/slack-summary.golden.txt new file mode 100644 index 00000000000..75727f56bdc --- /dev/null +++ b/tools/ccip/ccip-release-changelog/internal/changelog/testdata/slack-summary.golden.txt @@ -0,0 +1,14 @@ +*CCIP Release Changelog* `v2.55.0` → `v2.56.0` + · + +:warning: *Flags (5)* +• *chainlink-ccip*: keyword match — feat: add fast lane config ([`deadbeef0000`](https://github.com/smartcontractkit/chainlink-ccip/commit/deadbeef0000)) +• *chainlink-solana*: ROLLBACK — new pin `333333333333` is BEHIND old pin `333333333333` +• *chainlink-ton*: plugin `ton` gitRef changed `v1.0.5-0.20260101000000-111111111111` → `v1.0.5-0.20260202000000-555555555555` +• *chainlink-ton*: DRIFT at new ref — plugin `ton` pins `555555555555` but go.mod `chainlink-ton` pins `444444444444` +• *chainlink-ton*: keyword match — hotfix for gas estimator ([`cafe0000cafe`](https://github.com/smartcontractkit/chainlink-ton/commit/cafe0000cafe)) + +*Commits per repo* +chainlink-ccip: 2 · chainlink-aptos: ⚠️ error · chainlink-sui: no change · chainlink-solana: 0 · chainlink-ton: 1 · chainlink-evm: no change · chainlink: 1 + +Full report attached below :point_down: \ No newline at end of file