Skip to content

Commit 4063be9

Browse files
sectoramenunion
andauthored
chore(changelog): collect release notes as one fragment per PR under changelog.d/ (#832)
* chore(changelog): collect release notes as one fragment per PR under changelog.d/ Every PR that changes what a release ships added its entry under ## [Unreleased] in CHANGELOG.md, at the same few lines, so any two PRs open at once conflicted when the second merged (#811 vs #812/#813 on 2026-10-26, #812 vs #813 again on 2026-10-27). merge=union does not help: GitHub's merge ignores custom merge drivers. A file per PR cannot conflict with another PR's file (#821). - internal/tools/changelogfrag: the fragment grammar, shared by the release-time renderer and the PR check so they cannot disagree. One canonical heading, one blank line, "- " bullets with two-space continuations, a single final newline, nothing else; names NNNN-slug.md. Also locates the [Unreleased] block and parses old-style entries left under it. - internal/tools/changelogrender: prints the section a release adds (make changelog-preview), or with -write promotes it into CHANGELOG.md below the [Unreleased] heading and its prose. Headings in canonical order; old-style bullets first under a heading, then fragments by file name; every bullet line copied byte for byte. - scripts/promote-changelog.sh keeps its CLI, exit codes, idempotence and hard failure on a missing anchor, and is a no-op when nothing was collected. It runs the renderer, refuses to write unless the renderer's file is byte-identical to the cut it assembles itself and has old - legacy + rendered lines, then git rm's the fragments (README.md stays). A malformed or untracked fragment stops it before anything is written. - changelog-promote.yml sets up Go and stages changelog.d with the promote commit; trigger and push loop unchanged. * chore(changelog): move the [Unreleased] entries into changelog.d/ fragments The four entries under [Unreleased] (#765, #773, #770 under Bug Fixes; the Go 1.27.3 bump, #799, under Security) become changelog.d/0765-secrets-line-precheck-veto.md, 0773-redaction-overlapping-findings.md, 0770-redaction-cut-short-contract.md and 0799-build-go-1-27-3.md. Their bullet lines, concatenated in that order, are byte-identical to the section's (100 lines). [Unreleased] keeps its heading, which the promotion anchors on, and holds one paragraph pointing at changelog.d/. changelog.d/0821-changelog-fragments.md is this change's own note. * chore(changelog): require a fragment at PR time; drop the after-merge check changelogcheck now requires an added, well-formed fragment under changelog.d/ when a PR changes what a release ships (same no-changelog label and dependabot exemptions), and on every PR: - fails an added or edited fragment the release could not render (name, grammar, subdirectory, symlink), with the whole format in the message; - fails a PR that adds an entry-shaped line (heading, list item, indented line) under [Unreleased] in CHANGELOG.md: write a fragment instead. Prose there is warned, not failed, so the pointer paragraph can be edited; - fails a CHANGELOG.md whose [Unreleased] block the promotion would refuse (no exact anchor, two anchors, no blank line where the section goes, unparseable old-style entries). The post-merge mode (EvaluatePostMerge, -pr-head) and the after-merge job go: they caught a release carrying an open PR's entry into the section it had just promoted, and a fragment is a file no release moves. -base/-head stay: scripts/pr-checklist.sh passes them. * docs(changelog): document fragments in CONTRIBUTING, RELEASE_PROCESS and the PR template * test(changelog): make the renderer and check tests portable to Windows windows-latest failed TestRun: Windows has no Unix permission bits, so the 0640 the test set reads back as 0666. Assert the mode only off Windows. The symlink cases now skip where Windows refuses to create a symlink instead of failing, and the end-to-end check test hands each subtest its own *testing.T: its change closures called the parent test's Fatal from inside subtests, which only showed when one failed. --------- Co-authored-by: union <union@example.invalid>
1 parent 2bb3dea commit 4063be9

24 files changed

Lines changed: 2761 additions & 523 deletions

‎.github/pull_request_template.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,6 @@
99
## Checklist
1010

1111
- [ ] `make test` and `make build` pass
12-
- [ ] **CHANGELOG:** an entry under `## [Unreleased]` in `CHANGELOG.md` if this changes the shipped binary — or the `no-changelog` label if nothing user-visible changed ([CONTRIBUTING](https://github.com/awslabs/ferret-scan/blob/main/CONTRIBUTING.md#changelog-entries)). The `CHANGELOG` check reports on this.
12+
- [ ] **CHANGELOG:** a fragment `changelog.d/NNNN-slug.md` ([format](https://github.com/awslabs/ferret-scan/blob/main/changelog.d/README.md)) if this changes what a release ships — or the `no-changelog` label if nothing user-visible changed ([CONTRIBUTING](https://github.com/awslabs/ferret-scan/blob/main/CONTRIBUTING.md#changelog-entries)). Do not edit `## [Unreleased]` in `CHANGELOG.md`. The `CHANGELOG` check reports on this.
1313
- [ ] Golden corpus regenerated and the diff explained, if detection, scoring, output or redaction changed
1414
- [ ] `Closes #N` for any issue this resolves
Lines changed: 18 additions & 59 deletions
Original file line numberDiff line numberDiff line change
@@ -1,31 +1,32 @@
11
name: CHANGELOG
22

3-
# Fails a pull request that changes what a release ships without an entry under [Unreleased]. The
4-
# rules and the evidence for them live in internal/tools/changelogcheck/check.go; this file only
5-
# feeds it the PR.
3+
# Fails a pull request that changes what a release ships without adding a release-note fragment
4+
# under changelog.d/, and any pull request that adds an entry under [Unreleased] in CHANGELOG.md or
5+
# a fragment the release could not render. The rules and the evidence for them live in
6+
# internal/tools/changelogcheck/check.go, the fragment format in internal/tools/changelogfrag; this
7+
# file only feeds the check the PR.
68
#
7-
# WHY AT PR TIME. The release-time promotion (changelog-promote.yml) can only stamp what is already
8-
# written, and v2.5.3 shipped four binary-changing PRs with nothing written: promotion found
9-
# [Unreleased] empty and correctly did nothing. The author of the PR is the one person who knows
10-
# what changed, and this is the last moment they are looking.
9+
# WHY AT PR TIME. The release-time promotion (changelog-promote.yml) can only render what is already
10+
# written, and v2.5.3 shipped four binary-changing PRs with nothing written: promotion found nothing
11+
# and correctly did nothing. The author of the PR is the one person who knows what changed, and this
12+
# is the last moment they are looking.
13+
#
14+
# WHY FRAGMENTS (#821). Entries used to go under [Unreleased] in CHANGELOG.md, at the same few lines
15+
# for every PR, so any two PRs open at once conflicted when the second merged. A new file per PR
16+
# cannot conflict with another PR's new file.
1117
#
1218
# WHY THE MERGE RESULT. actions/checkout on a pull_request event checks out refs/pull/N/merge, so
13-
# HEAD^1 is the base tip and HEAD is what main will contain after the merge — the view that catches
14-
# an entry placed beside a subsection heading in an old release.
19+
# HEAD^1 is the base tip and HEAD is what main will contain after the merge.
1520
#
16-
# WHY ALSO AFTER THE MERGE. A pull_request result is computed once, against the main of that moment,
17-
# and nothing re-runs it when main moves. So a PR whose check passed before a release was promoted
18-
# can still merge its entry into the section that release just promoted — a clean three-way merge,
19-
# measured. The `after-merge` job closes that: on the push to main it compares where the PR's own
20-
# branch put the entry with where the merge put it, and fails on the main commit, which notifies the
21-
# person who merged it.
21+
# NO AFTER-MERGE JOB. Until #821 a second job ran on every push to main and failed when a release
22+
# promoted while a PR was open had carried the PR's entry, by a clean three-way merge, into the
23+
# section that release had just promoted. A fragment is a file of its own that no release moves, so
24+
# that failure no longer has a mechanism; the job and the push trigger it needed are gone.
2225
on:
2326
pull_request:
2427
# edited: the title (a `type!:` breaking marker) and the base branch are inputs.
2528
# labeled/unlabeled: `no-changelog`.
2629
types: [opened, edited, synchronize, reopened, labeled, unlabeled]
27-
push:
28-
branches: [main]
2930

3031
permissions:
3132
contents: read
@@ -54,45 +55,3 @@ jobs:
5455
PR_AUTHOR: ${{ github.event.pull_request.user.login }}
5556
PR_LABELS: ${{ toJSON(github.event.pull_request.labels.*.name) }}
5657
run: go run ./internal/tools/changelogcheck
57-
58-
after-merge:
59-
if: github.event_name == 'push'
60-
runs-on: ubuntu-24.04
61-
permissions:
62-
contents: read
63-
pull-requests: read # to find the PR this push merged
64-
steps:
65-
- name: Checkout main with history
66-
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
67-
with:
68-
# The PR's merge-base with the old main can be arbitrarily far back.
69-
fetch-depth: 0
70-
persist-credentials: false
71-
72-
- name: Set up Go
73-
uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0
74-
with:
75-
go-version-file: '.go-version'
76-
77-
- name: Find the pull request this push merged
78-
id: pr
79-
env:
80-
GH_TOKEN: ${{ github.token }}
81-
REPO: ${{ github.repository }}
82-
SHA: ${{ github.sha }}
83-
run: |
84-
number=$(gh api "repos/${REPO}/commits/${SHA}/pulls" --jq '[.[] | select(.merged_at != null)][0].number // empty')
85-
echo "number=${number}" >> "$GITHUB_OUTPUT"
86-
if [ -z "${number}" ]; then
87-
echo "No merged pull request for ${SHA} (a direct push); nothing to compare."
88-
fi
89-
90-
- name: Compare the branch's placement with the merge's
91-
if: steps.pr.outputs.number != ''
92-
env:
93-
NUMBER: ${{ steps.pr.outputs.number }}
94-
BEFORE: ${{ github.event.before }}
95-
AFTER: ${{ github.sha }}
96-
run: |
97-
git fetch --no-tags --quiet origin "refs/pull/${NUMBER}/head"
98-
go run ./internal/tools/changelogcheck -base "${BEFORE}" -head "${AFTER}" -pr-head "$(git rev-parse FETCH_HEAD)"

‎.github/workflows/changelog-promote.yml‎

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,13 @@ name: Promote CHANGELOG on release
33
# #647: `## [Unreleased]` had swallowed 27 releases because nothing cut it at release time -- and
44
# the one tool that ever tried (git-chglog, removed in #646) REGENERATED the file from commit
55
# subjects, destroying the hand-written prose. This workflow does the additive alternative: when a
6-
# release tag is pushed, rename [Unreleased] to that tag and insert a fresh empty [Unreleased]
7-
# above it. scripts/promote-changelog.sh owns the surgery and its guarantees (byte-preservation,
8-
# idempotence on a re-run, no-op on an empty section, hard failure on a missing anchor).
6+
# release tag is pushed, render the release notes collected for it -- one fragment file per pull
7+
# request under changelog.d/ (#821), plus any old-style bullets still under [Unreleased] -- into a
8+
# dated section right below the [Unreleased] heading, and remove the rendered fragments in the same
9+
# commit. scripts/promote-changelog.sh owns the surgery and its guarantees (byte-preservation of
10+
# every bullet line, idempotence on a re-run, no-op when nothing was collected, hard failure on a
11+
# missing anchor or a malformed fragment); the rendering is internal/tools/changelogrender, hence
12+
# the Go toolchain below.
913
#
1014
# TRIGGER: the TAG PUSH, not `release: published` -- and that is load-bearing, not a style choice.
1115
# goreleaser publishes the release with the workflow-provided GITHUB_TOKEN, and events performed
@@ -49,6 +53,12 @@ jobs:
4953
# meantime, and rebasing from a shallow clone fails with "unshallow" errors.
5054
fetch-depth: 0
5155

56+
# The renderer is Go; same pinned action and version source as changelog-check.yml.
57+
- name: Set up Go
58+
uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0
59+
with:
60+
go-version-file: '.go-version'
61+
5262
- name: Promote [Unreleased] to the released tag
5363
env:
5464
TAG: ${{ github.event_name == 'workflow_dispatch' && inputs.tag || github.ref_name }}
@@ -91,7 +101,8 @@ jobs:
91101
fi
92102
git config user.name "github-actions[bot]"
93103
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
94-
git add CHANGELOG.md
104+
# changelog.d: the script already `git rm`ed the rendered fragments; README.md stays.
105+
git add -- CHANGELOG.md changelog.d
95106
git commit -m "chore(changelog): promote [Unreleased] to ${TAG} [skip ci]"
96107
# goreleaser[bot] pushes the brew formula to main in the same minute; retry over it.
97108
for attempt in 1 2 3 4 5; do

‎CHANGELOG.md‎

Lines changed: 3 additions & 105 deletions
Original file line numberDiff line numberDiff line change
@@ -14,111 +14,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1414

1515
## [Unreleased]
1616

17-
### 🐛 Bug Fixes
18-
19-
- **secrets (line pre-check):** a `"url"`, `"checksum"` or version/boolean member anywhere on a line no
20-
longer switches off token and keyword detection for that line
21-
([#765](https://github.com/awslabs/ferret-scan/issues/765)). `containsSecretIndicators`, the pre-check
22-
in front of the keyword pass, ran three whole-line substring checks before its provider-literal
23-
admits, and a hit dropped the line: one `"url": "https://github.com/…"` member, a
24-
`"bucket": "shared-assets"` beside a `"checksum"` key (`shared` holds `sha`) or a trailing
25-
`"verbose": true` hid an AKIA key ID, a `ghp_`/`glpat-`/`xoxb-` token, a JWT or a keyword-assigned
26-
password anywhere else on it. Nothing reached the redactor, so no redacted copy was written,
27-
`--pre-commit-mode` exited 0 and the `--stdin --enable-redaction` gateway passed the token through.
28-
Measured on v2.5.5 with the issue's nine trigger files and their one-word controls: seven reported
29-
nothing where the control reported every value, one (`"url"` beside a 40-character password) lost
30-
its AKIA key ID on every draw and the password on 10 of 31, and one reported only the token that
31-
#766's URI source finds outside the pre-check; 99 of 143 cells of 13 secret shapes × 11 trigger
32-
contexts reported nothing; `glpat-`, `xoxb-`, AKIA, JWT and 12–20-character passwords were found 0
33-
of 40 times after a `"url"` or `"integrity"` member and 40 of 40 after `"name"`. Now the
34-
provider-literal admits (JWT, every provider prefix, PEM markers) run first, and the three checks
35-
are replaced by one veto that skips a line only when it is wholly one benign manifest member — a
36-
version under 20 bytes, a boolean, an SRI digest or a URL with no user info, query or `=` — with no
37-
keyword stem anywhere on it, and was skipped before. Every trigger now reports what its control
38-
reports (31 of 31 seeded draws), all 143 cells and every matrix cell (40 of 40) detect the value,
39-
the lockfile decoy stays silent, both redacted copies hold no value, both pre-commit runs exit 1
40-
and the gateway emits no token. **New findings only:** on the matrices,
41-
the reproduction, 39 public npm lockfiles and 3,691 public files that hit one of the old checks, no
42-
existing finding changes type, text, line or confidence, and main's suppression rules still suppress
43-
every one of them; on a line whose token in URL user info is also a provider literal, the finding's
44-
`detection_method` now reads `keyword_pattern` instead of `uri_userinfo`. One shape, absent from all
45-
of those sets, does change an existing finding: a password in the user info of a `"url"`,
46-
`"resolved"` or `"homepage"` URL on a registry or forge host whose user name ends in a keyword stem
47-
(`https://deploy-token:<pw>@registry…`) is now reported, as on every other line, as the keyword
48-
pass's wider `<pw>@host/path` span, so a rule written against the narrower finding must be
49-
regenerated. The veto still skips 99.1% of the lockfile lines the old checks skipped; SECRETS CPU on
50-
those lockfiles is 3–6% above v2.5.5 (deleting the checks outright measured 2.7x).
51-
- **redaction (overlapping findings):** two findings on one line whose values share bytes are now one
52-
write over their union, in every writer, so no part of either survives and which one is written no
53-
longer depends on input order ([#773](https://github.com/awslabs/ferret-scan/issues/773)). Each writer
54-
applied findings one at a time, locating each by its text in content an earlier write had already
55-
changed, so the second of two crossing values was skipped: `Residence: 77 August Lane, Boise ID 83702`
56-
(an address and two names) came out as `Residence: 77 August *********** ID 83702` from `.txt`,
57-
`--stdin`, `pkg/scan` and `pkg/redact`, as `**************, Boise ID 83702` from `.docx`, and as
58-
`77 ***********, Boise ID 83702` from legacy `.doc`/`.xls`/`.ppt`, which had no overlap pass at all and
59-
leaked containment too (`Located at 100 ****************** Blvd`) -- each at exit 0, with an audit log
60-
reporting `failed_redactions: 0`. For the same bytes reported twice (`PASSPORT` and `SSN` on one
61-
9-digit number) the writer's sort tied and `sort.Slice` broke the tie by algorithm, which changes at 13
62-
elements: `Passport number: 512010001` kept its last four digits (`*****0001`) or not depending on how
63-
many unrelated findings the file held. A group now renders by one rule: a contained finding collapses
64-
into the widest, as before, except that no original letter or digit inside a contained SECRETS finding
65-
is copied; the same bytes take the highest-confidence finding's label and keep a character only if
66-
every finding on them would; crossing findings are masked whole under `format_preserving` and take the
67-
highest-confidence placeholder under `simple`. Measured over the repository's 1,717 tracked files
68-
(11,451 findings): of 87 same-span pairs whose types disagree on the last four digits, 32 kept them and
69-
now 0 do; of 22 crossing pairs, 21 left part of a value in cleartext and now 0 do. Findings,
70-
recall/precision and suppression hashes are unchanged. A group that cannot be located refuses the file
71-
instead of writing part of it, and the audit log records one entry per group with every member's type
72-
in `member_types`. The `secrets_uri_userinfo_email_overlap` snapshot now masks the password's first
73-
character, which the email's mask used to keep.
74-
- **redaction, pkg/scan, pkg/redact (cut-short coverage):** a redaction that could not examine all of
75-
its input now fails on every sink the way `--file` always has, and a fired validator budget keeps
76-
what it found instead of discarding it and re-running the file
77-
([#770](https://github.com/awslabs/ferret-scan/issues/770)). Measured on v2.5.5: `--stdin
78-
--enable-redaction` exited 0 under `--fail-on-incomplete` on a cut-short scan (a 1ns budget, a log
79-
over the 5 MiB `CLOUD_RESOURCES` cap, `SSN=300ms` on 20,000 records), and in pre-commit mode `--stdin`
80-
printed 0 bytes of disclosure; `Engine.Redact` returned `err == nil` with the input unredacted for a
81-
pre-cancelled context, for 1, 100 and 200 ms deadlines (0 of 6,000 findings) and for a 5.8 MB
82-
`CLOUD_RESOURCES` request; `RedactFile` copied an unparseable `.pdf` byte-identical to the output
83-
path, and a log over the cap with 5,518 of 5,518 ARN lines in clear, at `err == nil`; `ScanText` and
84-
`ScanFile` ignored their context (30,000 of 30,000 findings under a pre-cancelled one). Now
85-
`--stdin` exits 3 under the flag on both of its paths and prints a one-line notice in pre-commit
86-
mode; `Engine.Redact` returns an error wrapping the new `redact.ErrIncomplete` and the cause, with
87-
the partial `Result` alongside (`Incomplete`, `IncompleteReason`, both also on `AuditRecord`);
88-
`RedactFile` and the new `RedactFileContext` return an error wrapping the new `scan.ErrIncomplete`
89-
with `RedactFileResult.Coverage` set (a pointer, so the struct stays comparable) and write no
90-
passthrough copy; `ScanText` and `ScanFile` return `(nil, ctx.Err())` for a context done at entry
91-
and the partial result with an error wrapping `ctx.Err()` when it fires mid-scan, and `ScanText`
92-
fills `NotExamined`; the web UI's `/scan` runs under the request's context. A fired budget is no
93-
longer retried as a transient fault (each re-ran every check on the file twice more: `SSN,EMAIL`
94-
with `SSN=1s` on records.txt cost 7.36–7.44 s CPU, now 3.22–3.29 s); the partial findings are kept
95-
(`SSN=1s`: 0 → 8,704–9,216 of 20,000 SSNs), scored and annotated exactly as a complete run scores
96-
them (each identical to its unbudgeted twin, metadata included); and a partial that arrives within
97-
100 ms of a caller's cancellation is collected (`Engine.Redact` on 2,000 records at a 100 ms
98-
deadline: 0 → 4,768 of 6,000 findings). Behaviour changes to know: library callers get an error
99-
where they got `(cleartext, nil)`; `examples/lambda-redact` answers `redaction incomplete` for a
100-
request over 5 MiB with `CLOUD_RESOURCES` enabled; budgeted runs report more findings and
101-
`--generate-suppressions` writes more rules; the `--stdin` warning's reason reads `coverage cut
102-
short: …`. Runs whose budget never fires are unchanged (golden corpus 0 files changed, `make score`
103-
identical).
104-
105-
### 🔒 Security
106-
107-
- **build (go 1.27.3):** move every Go pin to **1.27.3**, which carries the fix for the one open Go
108-
1.27.2 standard-library advisory, GO-2026-6809 / CVE-2026-111943 in `crypto/x509`: `Verify` now
109-
refuses a set of intermediate certificates that are no longer trusted, for the platforms whose
110-
system trust store is not updated without further action. The two open code-scanning alerts on
111-
`main` — #3842 (Trivy, HIGH) and #3843 (Grype) — are readings of the `ghcr.io/awslabs/ferret-scan:main`
112-
image built at v2.5.5, where the binary's embedded `stdlib` is go1.27.2, and the fixed versions
113-
both scanners name are 1.26.10 and 1.27.3. `.go-version` is the single source: `release.yml`,
114-
`pypi-publish.yml` and `go-test.yml` install it, and `scripts/go-version.sh all` propagated it to
115-
`go.mod` (unchanged at `go 1.27`), `.gitlab-ci.yml` and the Dockerfile builder, whose
116-
`golang:1.27.3-alpine` digest `sha256:b8c9b82a…` was checked against `public.ecr.aws` before
117-
adoption: every platform in the index, including linux/amd64 and linux/arm64, carries
118-
`GOLANG_VERSION=1.27.3` (built 2026-10-20 to 2026-10-22). `make check-go-version` is green on the
119-
branch. The `:main` image and its two alerts refresh on the merge to `main`; the published release
120-
artifacts — the v2.5.5 binaries, Homebrew formula, PyPI wheel and `:latest` container — stay on
121-
1.27.2 until the next tag is cut from this pin.
17+
Entries for the next release live as one file per pull request in [`changelog.d/`](changelog.d/)
18+
and become a dated section below this heading when the release tag is pushed;
19+
`make changelog-preview` shows that section now. Nothing else goes under this heading.
12220

12321
## [v2.5.5] - 2026-10-22
12422

0 commit comments

Comments
 (0)