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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
88 changes: 88 additions & 0 deletions .github/workflows/ccip-release-changelog.yml
Original file line number Diff line number Diff line change
@@ -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
6 changes: 6 additions & 0 deletions tools/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
133 changes: 133 additions & 0 deletions tools/ccip/ccip-release-changelog/README.md
Original file line number Diff line number Diff line change
@@ -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://<ws>.slack.com/archives/<channel>/p<ts>]
```

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/<ref>`.

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
```
114 changes: 114 additions & 0 deletions tools/ccip/ccip-release-changelog/cmd/ccip-release-changelog/main.go
Original file line number Diff line number Diff line change
@@ -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
}
Loading
Loading