🏗️ Build and Test JellyTau / Run Tests (pull_request) Successful in 15m1s
🏗️ Build and Test JellyTau / Supply Chain (pull_request) Successful in 42s
Traceability Validation / Check Requirement Traces (pull_request) Successful in 10s
🏗️ Build and Test JellyTau / Android Compile Check (pull_request) Successful in 4m6s
Two defects found while preparing v0.9.2, both of which had been shipping for months without anything to notice them by. **Every release note was the same 1,050 bytes.** All 35 releases from v0.0.1 to v0.9.1 published identical generic install instructions whose "What's New" section read "See CHANGELOG.md" -- a link that does not resolve from a release page. A reader learned nothing about what changed in any release the project has ever made. The body now comes from the `## <version>` section of CHANGELOG.md, and a missing section fails the release: notes that say nothing are worse than a build that waits for a maintainer to write two sentences. The 35 published bodies have been backfilled from the changelog via the tea CLI. This also corrects something introduced two commits ago. That change generated the body from `bun run release:notes`, which CLAUDE.md is explicit about -- its output is "a reviewed draft, not a final changelog". Publishing it unreviewed proved the point immediately: the v0.9.1..HEAD range contains a repo-wide prettier sweep, so every file in src/ counted as changed, their TRACES resolved to nearly the whole matrix, and the draft claimed the release had added the entire application. The script now skips cosmetic commits (chore(format), chore(deps), style) and reports how many rather than silently returning a smaller set, but it stays a local drafting tool. **Every release from v0.1.0 to v0.8.2 shipped every Windows installer ever built.** src-tauri/target/*/release/bundle/ is not versioned, cargo never cleans it, and the runner reuses the target directory -- so the copy step's bundle/**/*-setup.exe glob collected the lot. v0.8.2 carried sixteen installers, thirteen of them stale; v0.5.0 offered users a download list going back to 0.1.0. Eight months, and nothing to notice it by: the upload loop reported success, the files were real, and the page looked busy rather than wrong. It stopped only because an unrelated cargo cache change wiped the runner's target dir, so it was dormant, not fixed. Both desktop builds now remove the bundle directory before building, so a stale file cannot exist to be copied. Filtering the copy by version would have hidden it instead. The Linux job gets the same treatment: it was never hit only because Linux packaging is newer, and the glob is identical. scripts/check-release-artifacts.sh is the backstop for whatever reintroduces one by a route nobody predicted. It runs before the SBOM, the checksums and the upload -- all of which describe the file set, so a stale artifact has to be caught before it is hashed and published as part of the release. Verified against a reconstruction of the real v0.8.2 accumulation. DR-219, DR-220, UT-210.
214 lines
7.0 KiB
TypeScript
214 lines
7.0 KiB
TypeScript
#!/usr/bin/env bun
|
||
/**
|
||
* release-notes.ts — turn a commit range into capability-level release notes
|
||
* using the TRACES graph instead of raw commit subjects.
|
||
*
|
||
* Usage:
|
||
* bun run scripts/release-notes.ts [<range>]
|
||
* bun run scripts/release-notes.ts v0.0.15..HEAD
|
||
*
|
||
* With no argument it uses <latest tag>..HEAD (or the whole history if untagged).
|
||
*
|
||
* How it works:
|
||
* 1. `git diff --name-only <range>` → files the range changed.
|
||
* 2. Read each changed file's `TRACES:` comments → requirement IDs.
|
||
* 3. Resolve IDs to descriptions from docs/requirements.md.
|
||
* 4. Group: UR → Features, DR/IR → Improvements. Deduped, so many commits
|
||
* touching one requirement collapse to one line.
|
||
*
|
||
* This is a drafting aid for docs/release-checklist.md — review the output,
|
||
* it does not invent descriptions for untraced changes (those are listed
|
||
* separately so nothing is silently dropped).
|
||
*/
|
||
|
||
import { execSync } from "node:child_process";
|
||
import { readFileSync, existsSync } from "node:fs";
|
||
|
||
const TRACE_RE = /TRACES:\s*([^\n*]+)/g;
|
||
const ID_RE = /\b(UR|IR|DR|JA|UT|IT)-\d+\b/g;
|
||
const REQ_ROW_RE = /^\|\s*((?:UR|IR|DR|JA)-\d+)\s*\|\s*([^|]+?)\s*\|/;
|
||
|
||
function sh(cmd: string): string {
|
||
return execSync(cmd, { encoding: "utf8" }).trim();
|
||
}
|
||
|
||
function defaultRange(): string {
|
||
try {
|
||
const tag = sh("git describe --tags --abbrev=0");
|
||
return `${tag}..HEAD`;
|
||
} catch {
|
||
return ""; // no tags: fall through to whole-history diff
|
||
}
|
||
}
|
||
|
||
/** Map requirement ID → human description, parsed from docs/requirements.md. */
|
||
function loadRequirementDescriptions(): Map<string, string> {
|
||
const map = new Map<string, string>();
|
||
const text = readFileSync("docs/requirements.md", "utf8");
|
||
for (const line of text.split("\n")) {
|
||
const m = line.match(REQ_ROW_RE);
|
||
// First definition wins: the descriptive tables come before the later
|
||
// cross-reference tables, whose cells hold linked IDs (or "-"), not prose.
|
||
if (m && !map.has(m[1])) map.set(m[1], m[2].trim());
|
||
}
|
||
return map;
|
||
}
|
||
|
||
/**
|
||
* Commit subjects whose changes carry no requirement meaning.
|
||
*
|
||
* `chore(format)` / `style` rewrite files without changing behaviour;
|
||
* `chore(deps)` moves lockfiles. Anything else — including a bare `chore:` and
|
||
* `chore(release):` — is assumed to mean something and is kept.
|
||
*
|
||
* Anchored at the start of the subject on purpose: "fix(duration): format times
|
||
* over 24 hours" is a real fix to formatting *code*, not a formatting commit.
|
||
*/
|
||
const COSMETIC_SUBJECT = /^(chore\(format\)|chore\(deps\)|style)(\([^)]*\))?\s*:/i;
|
||
|
||
/**
|
||
* Does this commit subject describe a change with no requirement meaning?
|
||
*
|
||
* Exported for scripts/release-notes.test.ts.
|
||
*
|
||
* TRACES: | DR-219
|
||
*/
|
||
export function isCosmeticCommit(subject: string): boolean {
|
||
return COSMETIC_SUBJECT.test(subject.trim());
|
||
}
|
||
|
||
/**
|
||
* Files the range changed, excluding those touched only by cosmetic commits.
|
||
*
|
||
* Why not a plain `git diff --name-only <range>`: that is what this did, and a
|
||
* single repo-wide `prettier --write` inside the range made it report 199 files
|
||
* whose TRACES comments resolved to nearly the entire requirement matrix. The
|
||
* generated notes for v0.9.2 claimed the release had added the whole
|
||
* application — and build-release.yml publishes this output, so the noise would
|
||
* have shipped.
|
||
*
|
||
* Walking commit by commit and skipping the cosmetic ones keeps a file that a
|
||
* sweep *and* a real change both touched: it is still listed by the real
|
||
* commit. Only files touched exclusively by cosmetic commits drop out, which is
|
||
* exactly the intent.
|
||
*
|
||
* Merge commits produce no output from `git diff-tree` without `-m`, and are
|
||
* skipped deliberately: everything they merge is already in the range as its
|
||
* own commit, so including them would double-count.
|
||
*/
|
||
function changedFiles(range: string): string[] {
|
||
// Untagged repo: describe everything currently traced.
|
||
if (!range) {
|
||
return sh("git ls-files")
|
||
.split("\n")
|
||
.filter((f) => f && existsSync(f));
|
||
}
|
||
|
||
// NUL between hash and subject so a subject containing anything at all is safe.
|
||
const log = sh(`git log --no-merges --format=%H%x00%s ${range}`);
|
||
if (!log) return [];
|
||
|
||
const files = new Set<string>();
|
||
let skipped = 0;
|
||
|
||
for (const line of log.split("\n")) {
|
||
const [sha, ...subjectParts] = line.split("\u0000");
|
||
const subject = subjectParts.join("\u0000");
|
||
if (!sha) continue;
|
||
|
||
if (isCosmeticCommit(subject)) {
|
||
skipped++;
|
||
continue;
|
||
}
|
||
|
||
for (const f of sh(`git diff-tree --no-commit-id --name-only -r ${sha}`).split("\n")) {
|
||
if (f && existsSync(f)) files.add(f);
|
||
}
|
||
}
|
||
|
||
if (skipped > 0) {
|
||
// Say what was dropped rather than silently reporting a smaller set.
|
||
console.error(
|
||
`ℹ️ Skipped ${skipped} cosmetic commit(s) (formatting/deps) when deriving notes.`,
|
||
);
|
||
}
|
||
|
||
return [...files];
|
||
}
|
||
|
||
/** Collect requirement IDs referenced by TRACES comments in the given files. */
|
||
function idsFromFiles(files: string[]): Set<string> {
|
||
const ids = new Set<string>();
|
||
for (const file of files) {
|
||
let content: string;
|
||
try {
|
||
content = readFileSync(file, "utf8");
|
||
} catch {
|
||
continue;
|
||
}
|
||
for (const trace of content.matchAll(TRACE_RE)) {
|
||
for (const id of trace[1].matchAll(ID_RE)) ids.add(id[0]);
|
||
}
|
||
}
|
||
return ids;
|
||
}
|
||
|
||
function main() {
|
||
const range = process.argv[2] ?? defaultRange();
|
||
const descriptions = loadRequirementDescriptions();
|
||
const files = changedFiles(range);
|
||
const ids = idsFromFiles(files);
|
||
|
||
const features: string[] = []; // UR
|
||
const improvements: string[] = []; // DR / IR
|
||
const unknown: string[] = []; // traced but not in requirements.md
|
||
|
||
for (const id of [...ids].sort()) {
|
||
const desc = descriptions.get(id);
|
||
if (id.startsWith("UT") || id.startsWith("IT")) continue; // tests aren't notes
|
||
if (!desc) {
|
||
if (!id.startsWith("UT") && !id.startsWith("IT")) unknown.push(id);
|
||
continue;
|
||
}
|
||
const line = `- ${desc} (${id})`;
|
||
if (id.startsWith("UR")) features.push(line);
|
||
else improvements.push(line);
|
||
}
|
||
|
||
const header = range || "(entire history — no tags found)";
|
||
const out: string[] = [`## Release notes — ${header}`, ""];
|
||
|
||
if (features.length) out.push("### ✨ Features", ...features, "");
|
||
if (improvements.length) out.push("### 🚀 Improvements", ...improvements, "");
|
||
if (unknown.length)
|
||
out.push(
|
||
"### ⚠️ Traced IDs missing from requirements.md",
|
||
...unknown.map((id) => `- ${id}`),
|
||
"",
|
||
);
|
||
|
||
const untraced = files.filter((f) => {
|
||
try {
|
||
return !/TRACES:/.test(readFileSync(f, "utf8"));
|
||
} catch {
|
||
return false;
|
||
}
|
||
});
|
||
if (untraced.length)
|
||
out.push(
|
||
`### 📝 Changed files without TRACES (${untraced.length}) — review manually`,
|
||
...untraced.map((f) => `- ${f}`),
|
||
"",
|
||
);
|
||
|
||
if (!features.length && !improvements.length)
|
||
out.push("_No traced requirements in this range._", "");
|
||
|
||
console.log(out.join("\n"));
|
||
}
|
||
|
||
// Guarded so this module stays importable from release-notes.test.ts.
|
||
if (import.meta.main) {
|
||
main();
|
||
}
|