Skip to content
Merged
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
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ Behavioral facts verified against host source; getting these wrong is the histor
Releases are **CI-gated** by `.github/workflows/release.yml`: pushing a `v<version>` tag replays the full CI matrix (unit + real-host e2e, ubuntu+windows+macOS — reused from `ci.yml` via `workflow_call`) and only then publishes to npm with provenance and creates the GitHub Release. There is deliberately **no** `NPM_TOKEN` anywhere — npm's trusted-publisher binding only accepts this workflow's OIDC identity, so local `npm publish` fails by design.

- Release flow (**bump-at-release-time**): pick the semver level from the changes accumulated on `main` since the last tag, run `npm version <patch|minor|major> --no-git-tag-version` (bumps `package.json` and syncs `package-lock.json` in one step), commit as `chore: release vX.Y.Z` on a release branch, open a PR and merge it (direct pushes to `main` are rejected — the ruleset has no bypass actors). Then `git checkout main && git pull --ff-only` and `v=$(node -p "require('./package.json').version") && git tag -m "v$v" "v$v" && git push origin "v$v"`. **The tag must point at the merged `main` HEAD** — with a squash merge the release branch's local commit is not in `main`'s history, and `v*` tags are immutable, so a tag on the wrong commit cannot be moved without ruleset surgery. The `-m` is not optional on machines with `tag.gpgSign=true` (a bare `git tag` opens an editor and fails non-interactively). The guard job fails the run when tag ≠ package.json version or the version is already on npm.
- **Changelog is generated during `npm version`**: the `version` lifecycle script runs `scripts/changelog.ts`, which turns CHANGELOG.md's `[Unreleased]` into a dated section for the new version — curated entries carry over verbatim, conventional commits since the previous tag are appended as draft entries (`chore: release` excluded), link definitions refresh, the file is left staged. **Review/trim the draft before the release commit.** Re-running is a no-op once the section exists. The release guard additionally fails when CHANGELOG.md lacks the `## [X.Y.Z]` section, and the GitHub Release body is extracted from that section (no more `--generate-notes`).
- **Changelog is generated during `npm version`**: the `version` lifecycle script runs `scripts/changelog.ts`, which turns CHANGELOG.md's `[Unreleased]` into a dated section for the new version — curated entries carry over verbatim, conventional commits since the previous tag are appended as draft entries (deduplicated: a commit is skipped when a curated entry claims its hash in backticks — curators annotate covered commits like `… as described. (\`aaa1111\`)` and may trim the claims at release review — or when its text, normalized for case, trailing punctuation, `(#N)` PR refs and hash annotations, matches a curated line or an earlier commit; `chore: release` excluded), link definitions refresh, the file is left staged. **Review/trim the draft before the release commit.** Re-running is a no-op once the section exists. The release guard additionally fails when CHANGELOG.md lacks the `## [X.Y.Z]` section, and the GitHub Release body is extracted from that section (no more `--generate-notes`).
- The `npm publish` step must stay directly in `release.yml`: npm validates the *calling* workflow's filename against the trusted-publisher binding — a publish hidden behind `workflow_call` would mismatch (reusing `ci.yml` for the test matrix only is fine).
- One-time bootstrap (**done** 2026-10-03 — the binding is live and the v0.4.4 rehearsal published through it; keep for forks/re-creation): trusted publishing can only be configured once the package exists on npm (npm/cli#8544), so the **first** publish is a one-time manual `npm publish` (that one version carries no provenance). Here the package-creating publish was a minimal `0.0.0-stage` placeholder (2 files, `"stub": true`), followed ~1 min later by the real manual `0.4.3`; the stub was **unpublished 2026-10-04** (see next bullet) and no longer exists. Then npmjs.com → package → Settings → Trusted publisher → GitHub Actions: org/user `bytesnail`, repo `opencode-secrets-env`, workflow filename `release.yml` (case-sensitive, `.yml` included; npm does not validate the fields until a publish runs), allowed action `npm publish`.
- Removing a stray npm version (playbook verified 2026-10-04): `npm unpublish <pkg>@<version>` only works within **72 h** of that version's publish; later it's npm-support territory. Pre-checks: `npm view <pkg> dist-tags` (never unpublish the `latest` target — re-tag first) and per-version downloads at `https://api.npmjs.org/versions/<pkg>/last-week` (zero = safe). Gotchas hit in practice: the maintainer machine's registry is npmmirror (read-only mirror) — **every write needs `--registry https://registry.npmjs.org`**; `npm login --registry https://registry.npmjs.org --auth-type=web` prints a URL and polls, no TTY needed; with 2FA on the account, writes fail EOTP and npm **redacts the `https://www.npmjs.com/auth/cli/<authId>` URL as `***` in non-TTY output and logs** (authId is treated as a secret), so the web-OTP flow looks unreachable headless — workaround: run under a pseudo-TTY, `script -qec "npm unpublish <pkg>@<version> --registry https://registry.npmjs.org --browser=false" /tmp/log` in the background; npm's `otplease` then prints the real URL unredacted, polls the `doneUrl`, and auto-retries the unpublish once the maintainer completes browser auth (works with any account factor — passkey/security key/TOTP — no TOTP secret needed locally). Verify: `npm view <pkg> versions` no longer lists it and the tarball URL 404s; the npmjs.com versions tab and npmmirror lag a few minutes.
Expand Down
4 changes: 2 additions & 2 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,10 +14,10 @@ fixes, minor for features.
where no event arrived within 60 s. The event watcher is now backed by a
low-frequency mtime poll (default 5 s; new `pollIntervalMs` option), so a
dropped event degrades to a few seconds' delay instead of a missed reload
until restart.
until restart. (`78134e8`)
- Unit tests are now hermetic on machines that export ambient
`XDG_*`/`OPENCODE_*` variables: the test entry point strips them, and the
inline-content rescan test pins its scan input.
inline-content rescan test pins its scan input. (`8bdfe06`, `d0cb49f`)

## [0.5.0] - 2026-10-04

Expand Down
42 changes: 40 additions & 2 deletions scripts/changelog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,12 @@
// bumps package.json and then runs this: CHANGELOG.md's [Unreleased] section
// becomes a dated section for the new version — entries already curated
// under [Unreleased] are carried over verbatim, then the conventional
// commits since the previous tag are appended as draft entries — and the
// link definitions at the bottom are refreshed. The file is left staged
// commits since the previous tag are appended as draft entries —
// deduplicated: a commit is skipped when a curated entry claims its hash
// in backticks (curators should annotate covered commits this way, e.g.
// "… as described. (`aaa1111`)"), or when its normalized text matches a
// curated line or an earlier commit — and the link definitions at the
// bottom are refreshed. The file is left staged
// (see the `version` script in package.json); review/trim the generated
// entries before committing `chore: release vX.Y.Z`.
//
Expand Down Expand Up @@ -54,6 +58,26 @@ export function classify(subject: string): { bucket: string; text: string } | nu
return null
}

// Normalizes a changelog bullet for text deduplication: drops the leading
// "- ", any trailing commit-hash annotation or "(#N)" PR ref, trailing
// punctuation, and case — so a curated "- Plug leak." matches the generated
// "- Plug leak (`aaa1111`)". Curated prose is usually worded nothing like
// the commit subject, though, so text matching alone cannot dedupe the
// real cases: curated entries claim the commits they cover by hash.
export function dedupeKey(entry: string): string {
return entry
.replace(/^- /, "")
.replace(/\s*\((`[0-9a-f]+`(, )?)+\)\.?$/i, "")
.replace(/\s*\(#\d+\)$/, "")
.replace(/[\s.]+$/, "")
.toLowerCase()
}

// A backtick-quoted hex string (7-40 chars) anywhere in the curated section
// claims that commit: its generated draft entry is skipped. Abbreviated and
// full hashes match each other (prefix comparison either way).
const HASH_CLAIM = /`([0-9a-f]{7,40})`/gi

// Merges generated bucket items into the curated section: buckets whose
// `### <Name>` heading already exists get their items appended in place;
// the rest become new subsections (in BUCKETS order) after the curated text.
Expand Down Expand Up @@ -100,10 +124,24 @@ export function renderChangelog(text: string, opts: RenderOptions): string | nul
if (nextAt < 0) nextAt = text.length
const curated = text.slice(headingEnd, nextAt).trim()

// Dedupe: collect hash claims and seed the seen-set with the curated
// bullets, then drop claimed commits and keep only the first generated
// entry per normalized text (commits arrive oldest-first).
const claims: string[] = []
for (const m of curated.matchAll(HASH_CLAIM)) claims.push(m[1]!.toLowerCase())
const seen = new Set<string>()
for (const line of curated.split("\n")) {
const bullet = line.trimStart()
if (bullet.startsWith("- ")) seen.add(dedupeKey(bullet))
}
const byBucket = new Map<string, string[]>()
for (const { hash, subject } of commits) {
const c = classify(subject)
if (!c) continue
if (claims.some((claim) => claim.startsWith(hash) || hash.startsWith(claim))) continue
const key = dedupeKey(c.text)
if (seen.has(key)) continue
seen.add(key)
let items = byBucket.get(c.bucket)
if (!items) {
items = []
Expand Down
91 changes: 90 additions & 1 deletion test/changelog.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import assert from "node:assert/strict"
import { test } from "node:test"
import { classify, renderChangelog } from "../scripts/changelog.ts"
import { classify, dedupeKey, renderChangelog } from "../scripts/changelog.ts"

const REPO = "https://github.com/example/repo"

Expand Down Expand Up @@ -72,6 +72,95 @@ test("renderChangelog carries curated entries, appends buckets, refreshes links"
assert.match(out, /\[0\.4\.5\]: https:\/\/github\.com\/example\/repo\/compare\/v0\.4\.4\.\.\.v0\.4\.5/)
})

test("dedupeKey normalizes bullets, hashes, PR refs, punctuation and case", () => {
assert.equal(dedupeKey("- Plug leak (`aaa1111`)"), "plug leak")
assert.equal(dedupeKey("- Plug leak (`aaa1111`, `bbb2222`)."), "plug leak")
assert.equal(dedupeKey("- Plug Leak."), "plug leak")
assert.equal(dedupeKey("Plug leak"), "plug leak")
assert.equal(dedupeKey("Back the watcher (#6)"), "back the watcher")
assert.equal(dedupeKey("- Back the watcher (#6) (`aaa1111`)"), "back the watcher")
assert.notEqual(dedupeKey("- Add flag"), dedupeKey("- Add flag validation"))
})

test("renderChangelog skips commits a curated entry claims by hash, however worded", () => {
const text = `# Changelog

## [Unreleased]

### Fixed

- Prose write-up worded nothing like the commit subject, with the claim on
a continuation line. (\`aaa1111\`)
- Another entry claiming several commits at once (\`bbb2222\`, \`ccc333344445555\`).

## [0.5.0] - 2026-10-04

- Old.

[Unreleased]: ${REPO}/compare/v0.5.0...HEAD
[0.5.0]: ${REPO}/compare/v0.4.5...v0.5.0
`
const out = renderChangelog(text, {
version: "0.5.1",
date: "2026-10-05",
prevTag: "v0.5.0",
repoUrl: REPO,
commits: [
{ hash: "aaa1111", subject: "fix: totally different wording" },
{ hash: "bbb2222", subject: "feat: multi-claim first" },
{ hash: "ccc3333", subject: "feat: multi-claim second" },
{ hash: "ddd4444", subject: "fix: unclaimed sibling" },
],
})
assert.ok(out)
assert.ok(!out.includes("Totally different wording"), "claimed commit dropped")
assert.ok(!out.includes("Multi-claim first"), "first of multi-claim dropped")
assert.ok(!out.includes("Multi-claim second"), "full-hash claim matches abbreviated %h")
assert.ok(out.includes("- Unclaimed sibling (`ddd4444`)"), "unclaimed commit kept")
// The claims ride along verbatim as part of the curated entries.
assert.ok(out.includes("(`aaa1111`)"), "curated entry carried verbatim with its claim")
})

test("renderChangelog text-dedupes despite GitHub's '(#N)' squash suffix", () => {
const text = `# Changelog\n\n## [Unreleased]\n\n### Internal\n\n- Add single 'gate' check context.\n`
const out = renderChangelog(text, {
version: "0.5.1",
date: "2026-10-05",
prevTag: "v0.5.0",
repoUrl: REPO,
commits: [{ hash: "eee5555", subject: "ci: add single 'gate' check context (#3)" }],
})
assert.ok(out)
assert.equal(out.match(/gate' check context/g)?.length, 1, "PR-ref suffix does not defeat dedup")
assert.ok(!out.includes("eee5555"))
})

test("renderChangelog drops generated entries duplicating curated lines or earlier commits", () => {
const out = renderChangelog(FIXTURE, {
version: "0.5.1",
date: "2026-10-05",
prevTag: "v0.5.0",
repoUrl: REPO,
commits: [
// Same text as the curated Fixed line (curated has no hash, ends with ".").
{ hash: "aaa1111", subject: "fix: a hand-curated entry already written during development" },
// Same subject twice: only the first (oldest) survives.
{ hash: "bbb2222", subject: "fix: plug leak" },
{ hash: "ccc3333", subject: "fix: plug leak" },
// Same text under a different bucket is still a duplicate.
{ hash: "ddd4444", subject: "feat: plug leak" },
// Merely sharing a prefix is not a duplicate.
{ hash: "eee5555", subject: "fix: plug leak detection" },
],
})
assert.ok(out)
assert.equal(out.match(/hand-curated entry/g)?.length, 1, "curated entry not duplicated")
assert.equal(out.match(/- Plug leak \(`bbb2222`\)/g)?.length, 1, "first occurrence kept")
assert.ok(!out.includes("ccc3333"), "second same-subject commit dropped")
assert.ok(!out.includes("ddd4444"), "cross-bucket duplicate dropped")
assert.ok(out.includes("- Plug leak detection (`eee5555`)"), "prefix-only lookalike kept")
})

test("renderChangelog is a no-op when the version section exists", () => {
assert.equal(
renderChangelog(FIXTURE, {
Expand Down
Loading