diff --git a/.github/workflows/bk-desktop-release.yml b/.github/workflows/bk-desktop-release.yml index 6208cc63d1f5..25d8b4f0cb84 100644 --- a/.github/workflows/bk-desktop-release.yml +++ b/.github/workflows/bk-desktop-release.yml @@ -256,14 +256,18 @@ jobs: # The only job that can write releases, and it runs no build scripts and # holds no signing key. contents: write - # Gate production behind a protected environment so a merge to bkmain cannot - # publish to the team without whatever reviewers that environment requires. + # Both channels publish automatically: a merge to bkmain ships the production + # app without waiting for anyone, the same way expbkmain already shipped + # staging. The required-reviewer rule that used to gate `bk-desktop-production` + # was removed from the environment's settings, not from here — protection + # rules live in repository settings and no workflow file can express their + # absence. If a build must be held again, add the reviewer back there. # - # Both branches of this expression name a REAL environment on purpose. - # Resolving to an empty string to mean "no environment" is a widely used - # trick that GitHub does not actually document, and this is the first-publish - # path — not somewhere to find out. `bk-desktop-staging` exists with no - # protection rules, so it never blocks; it just records the deployment. + # The environments themselves stay, and both branches of this expression name + # a REAL one on purpose: they record each deployment, and resolving to an + # empty string to mean "no environment" is a widely used trick that GitHub + # does not actually document — this is the first-publish path, not somewhere + # to find out. environment: >- ${{ needs.build.outputs.channel == 'production' && 'bk-desktop-production' diff --git a/scripts/publish-bk-desktop-dmg.test.ts b/scripts/publish-bk-desktop-dmg.test.ts index 7fe7e2b3dfe1..05c9622d7d52 100644 --- a/scripts/publish-bk-desktop-dmg.test.ts +++ b/scripts/publish-bk-desktop-dmg.test.ts @@ -1,6 +1,9 @@ import { describe, expect, it } from "vite-plus/test"; -import { fingerprintDesignatedRequirement } from "./publish-bk-desktop-dmg.ts"; +import { + fingerprintDesignatedRequirement, + summarizeCommitSubjects, +} from "./publish-bk-desktop-dmg.ts"; // A real Apple-issued requirement embeds the developer's email and Team ID in // the certificate common name. This repository is public, so the raw string must @@ -39,3 +42,40 @@ describe("fingerprintDesignatedRequirement", () => { ); }); }); + +describe("summarizeCommitSubjects", () => { + it("keeps the subject line and drops the body", () => { + expect( + summarizeCommitSubjects(["fix(web): stop the panel flickering\n\nLong explanation."]), + ).toEqual(["fix(web): stop the panel flickering"]); + }); + + it("drops merge commits, which would otherwise list every change twice", () => { + expect( + summarizeCommitSubjects([ + "Merge pull request #118 from beknown-work/t3code/plan-comment-box", + "fix(planreview): dock the comment box under the plan", + ]), + ).toEqual(["fix(planreview): dock the comment box under the plan"]); + }); + + it("collapses duplicate subjects from a rebase or cherry-pick", () => { + expect(summarizeCommitSubjects(["fix: same", "fix: same", "fix: other"])).toEqual([ + "fix: same", + "fix: other", + ]); + }); + + it("summarises the tail instead of printing a wall of commits", () => { + const many = Array.from({ length: 20 }, (_, index) => `fix: change ${index}`); + const summarized = summarizeCommitSubjects(many, 15); + + expect(summarized).toHaveLength(16); + expect(summarized[15]).toBe("…and 5 more commits."); + }); + + it("returns nothing when there is nothing to say", () => { + expect(summarizeCommitSubjects([])).toEqual([]); + expect(summarizeCommitSubjects(["Merge branch 'bkmain' into expbkmain", " ", ""])).toEqual([]); + }); +}); diff --git a/scripts/publish-bk-desktop-dmg.ts b/scripts/publish-bk-desktop-dmg.ts index ec82bdf735bd..6b4356d87ffa 100644 --- a/scripts/publish-bk-desktop-dmg.ts +++ b/scripts/publish-bk-desktop-dmg.ts @@ -363,6 +363,80 @@ const runCommand = Effect.fn("runCommand")(function* ( const runGh = (args: ReadonlyArray) => runCommand("gh", args); +/** How many change lines a release body carries before it summarises the rest. */ +const CHANGELOG_LIMIT = 15; + +/** + * Turns commit subjects into the short "what changed" list a release body wants. + * + * Merge commits are dropped: on this fork every change arrives through a merge, + * so keeping them would list each change twice — once as "Merge pull request + * #N", once as the work itself. The conventional-commit prefix is kept because + * it is the fastest way to see whether a build is a fix or a feature. + */ +export function summarizeCommitSubjects( + messages: ReadonlyArray, + limit: number = CHANGELOG_LIMIT, +): ReadonlyArray { + const subjects = messages + .map((message) => message.split("\n", 1)[0]?.trim() ?? "") + .filter((subject) => subject.length > 0 && !subject.startsWith("Merge ")); + const unique = [...new Set(subjects)]; + if (unique.length <= limit) return unique; + return [...unique.slice(0, limit), `…and ${unique.length - limit} more commits.`]; +} + +const GhCompare = Schema.Struct({ + commits: Schema.Array(Schema.Struct({ commit: Schema.Struct({ message: Schema.String }) })), +}); +const decodeGhCompare = Schema.decodeUnknownEffect(Schema.fromJsonString(GhCompare)); + +const GhReleaseTarget = Schema.Struct({ targetCommitish: Schema.String }); +const decodeGhReleaseTarget = Schema.decodeUnknownEffect(Schema.fromJsonString(GhReleaseTarget)); + +/** + * The commits between the previous build of this channel and this one. + * + * Resolved through the compare API rather than `git log` because the publish job + * checks out at depth 1 and has no history to walk. + * + * Never fails the publish. A release that ships without its change list is a + * cosmetic loss; a release that does not ship because the notes could not be + * assembled is an outage of the update channel. + */ +const collectChangelog = Effect.fn("collectChangelog")(function* ( + previousTag: string | undefined, + targetSha: string, +) { + if (previousTag === undefined) return [] as ReadonlyArray; + + const viewed = yield* runGh([ + "release", + "view", + previousTag, + "--repo", + BK_DESKTOP_RELEASE_REPOSITORY, + "--json", + "targetCommitish", + ]); + if (viewed.exitCode !== 0) return [] as ReadonlyArray; + + const target = yield* decodeGhReleaseTarget(viewed.stdout).pipe(Effect.orElseSucceed(() => null)); + const previousSha = target?.targetCommitish; + if (!previousSha || previousSha === targetSha) return [] as ReadonlyArray; + + const compared = yield* runGh([ + "api", + `repos/${BK_DESKTOP_RELEASE_REPOSITORY}/compare/${previousSha}...${targetSha}`, + ]); + if (compared.exitCode !== 0) return [] as ReadonlyArray; + + const parsed = yield* decodeGhCompare(compared.stdout).pipe(Effect.orElseSucceed(() => null)); + if (parsed === null) return [] as ReadonlyArray; + + return summarizeCommitSubjects(parsed.commits.map((entry) => entry.commit.message)); +}); + const listPublishedTags = Effect.fn("listPublishedTags")(function* () { const result = yield* runGh([ "release", @@ -723,6 +797,11 @@ const command = Command.make( // Guard 3: the version must be strictly newer *within its own channel*, or // the updater ignores it. The other app's releases are irrelevant here. const newest = resolveNewestNightlyVersion(publishedTags, variant); + // The most recent published build of *this* channel is what the reader is + // upgrading from, so it is the right base for "what changed". + const previousTag = publishedTags.find((candidate) => + candidate.includes(`-${brand.updateChannel}-nightly.`), + ); if (newest && compareNightlyVersions(parsed, newest) <= 0) { return yield* new ReleaseVersionNotNewerError({ version, @@ -758,9 +837,13 @@ const command = Command.make( const signature = yield* describeSignature(signedAppPath); const sourceBranch = variant === "staging" ? "expbkmain" : "bkmain"; + const changelog = yield* collectChangelog(previousTag, targetSha); const notes = [ `Beknown fork desktop build — **${brand.productName}**.`, ``, + ...(changelog.length > 0 + ? [`**What changed**`, ``, ...changelog.map((line) => `- ${line}`), ``] + : []), `| | |`, `|---|---|`, `| Source branch | \`${sourceBranch}\` |`,