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
18 changes: 11 additions & 7 deletions .github/workflows/bk-desktop-release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
42 changes: 41 additions & 1 deletion scripts/publish-bk-desktop-dmg.test.ts
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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([]);
});
});
83 changes: 83 additions & 0 deletions scripts/publish-bk-desktop-dmg.ts
Original file line number Diff line number Diff line change
Expand Up @@ -363,6 +363,80 @@ const runCommand = Effect.fn("runCommand")(function* (

const runGh = (args: ReadonlyArray<string>) => 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<string>,
limit: number = CHANGELOG_LIMIT,
): ReadonlyArray<string> {
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<string>;

const viewed = yield* runGh([
"release",
"view",
previousTag,
"--repo",
BK_DESKTOP_RELEASE_REPOSITORY,
"--json",
"targetCommitish",
]);
if (viewed.exitCode !== 0) return [] as ReadonlyArray<string>;

const target = yield* decodeGhReleaseTarget(viewed.stdout).pipe(Effect.orElseSucceed(() => null));
const previousSha = target?.targetCommitish;
if (!previousSha || previousSha === targetSha) return [] as ReadonlyArray<string>;

const compared = yield* runGh([
"api",
`repos/${BK_DESKTOP_RELEASE_REPOSITORY}/compare/${previousSha}...${targetSha}`,
]);
if (compared.exitCode !== 0) return [] as ReadonlyArray<string>;

const parsed = yield* decodeGhCompare(compared.stdout).pipe(Effect.orElseSucceed(() => null));
if (parsed === null) return [] as ReadonlyArray<string>;

return summarizeCommitSubjects(parsed.commits.map((entry) => entry.commit.message));
});

const listPublishedTags = Effect.fn("listPublishedTags")(function* () {
const result = yield* runGh([
"release",
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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}\` |`,
Expand Down
Loading