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
1 change: 1 addition & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -255,6 +255,7 @@ jobs:

- name: Build signed and notarized DMG
env:
OFFICIAL_RELEASE: "1"
RELEASE_APP_VERSION: ${{ steps.release.outputs.version }}
RELEASE_DMG_NAME: ${{ steps.release.outputs.dmg_name }}
KWT_SOURCE_DIR: ${{ github.workspace }}/.release-inputs/kwt-source
Expand Down
7 changes: 7 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,9 @@ DEBUG_APP_PATH ?= $(DEBUG_ROOT)/$(GHOSTHUB_APP).app
RELEASE_APP_PATH ?= $(RELEASE_ROOT)/$(GHOSTHUB_APP).app
RELEASE_BUNDLE_ID ?= com.ghosthub
RELEASE_CHANNEL ?= stable
# Only the release workflow sets this. Other stable bundles carry their git
# description, which telemetry reports instead of the release version.
OFFICIAL_RELEASE ?=
NIGHTLY_SPARKLE_FEED_URL ?=
NIGHTLY_SPARKLE_PUBLIC_ED_KEY ?=
NIGHTLY_SOURCE_REVISION ?=
Expand Down Expand Up @@ -474,6 +477,10 @@ release-app: ensure-kwt ensure-kwt-variants build-release
--kwt-version "$(KWT_VERSION)" \
--kwt-source-revision "$(KWT_SOURCE_REVISION)" \
--remote-kwt-source-revision "$(KWT_REF)"); \
if [[ "$(RELEASE_CHANNEL)" == "stable" && "$(OFFICIAL_RELEASE)" != "1" ]]; then \
local_version="$$($(PYTHON) tools/development_version.py --component local)"; \
assemble_arguments+=(--development-version "$$local_version"); \
fi; \
if [[ "$(RELEASE_CHANNEL)" == "nightly" ]]; then \
assemble_arguments+=( \
--nightly-feed-url "$(NIGHTLY_SPARKLE_FEED_URL)" \
Expand Down
6 changes: 3 additions & 3 deletions Sources/App/Telemetry.swift
Original file line number Diff line number Diff line change
Expand Up @@ -29,9 +29,9 @@ struct TelemetryConfiguration: Sendable {
return TelemetryConfiguration(
projectToken: postHogProjectToken,
endpoint: postHogEndpoint,
version: bundle.object(
forInfoDictionaryKey: "CFBundleShortVersionString"
) as? String ?? "unknown",
version: ApplicationVersion.aboutPanelVersion(
infoDictionary: bundle.infoDictionary ?? [:]
) ?? "unknown",
build: bundle.object(
forInfoDictionaryKey: "CFBundleVersion"
) as? String ?? "unknown"
Expand Down
60 changes: 60 additions & 0 deletions Tests/App/TelemetryTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -480,4 +480,64 @@ struct TelemetryTests {
)
)
}

@Test("only bundles without a development version report a release version")
func liveConfigurationReportsAboutVersion() throws {
let releaseBundle = try telemetryTestBundle(info: [
"CFBundleIdentifier": "com.ghosthub",
"CFBundleShortVersionString": "0.3.0",
"CFBundleVersion": "118",
])
defer {
try? FileManager.default.removeItem(at: releaseBundle.bundleURL)
}
let localBundle = try telemetryTestBundle(info: [
"CFBundleIdentifier": "com.ghosthub",
"CFBundleShortVersionString": "0.3.0",
"CFBundleVersion": "190",
"GhosthubDevelopmentVersion": "0.3.0-72-g3c67741",
])
defer {
try? FileManager.default.removeItem(at: localBundle.bundleURL)
}

let release = try #require(
TelemetryConfiguration.live(
bundle: releaseBundle,
environment: [:]
)
)
#expect(release.version == "0.3.0")
#expect(release.build == "118")

let local = try #require(
TelemetryConfiguration.live(
bundle: localBundle,
environment: [:]
)
)
#expect(local.version == "0.3.0-72-g3c67741")
#expect(local.build == "190")
}
}

private func telemetryTestBundle(info: [String: Any]) throws -> Bundle {
let bundleURL = FileManager.default.temporaryDirectory
.appendingPathComponent(
"ghosthub-telemetry-\(UUID().uuidString).app"
)
let contentsURL = bundleURL.appendingPathComponent("Contents")
try FileManager.default.createDirectory(
at: contentsURL,
withIntermediateDirectories: true
)
try PropertyListSerialization.data(
fromPropertyList: info,
format: .xml,
options: 0
).write(to: contentsURL.appendingPathComponent("Info.plist"))
guard let bundle = Bundle(url: bundleURL) else {
throw CocoaError(.fileReadUnknown)
}
return bundle
}
7 changes: 5 additions & 2 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -448,8 +448,11 @@ are one interprocess-locked transaction, so simultaneous Ghosthub instances
share the same identity and only one schedules the event. The claim is
persisted before networking so an accepted event with a lost response is not
retried. Event properties are limited to the application name, native-app
source, version, and build number. Events explicitly disable PostHog
person-profile processing and GeoIP enrichment. Repository, worktree, host,
source, version, and build number. The version is the one the About panel
shows, so only published stable releases report a bare `X.Y.Z`; nightly and
locally packaged builds report their development version. Events explicitly
disable PostHog person-profile processing and GeoIP enrichment. Repository,
worktree, host,
session, path, command, and terminal data are outside the telemetry contract.

Anonymous usage reporting is enabled by default in packaged releases. Users
Expand Down
4 changes: 3 additions & 1 deletion docs/development.md
Original file line number Diff line number Diff line change
Expand Up @@ -176,7 +176,9 @@ make release-app

The app version comes from `RELEASE_VERSION`; the build number defaults to the
current commit count. Override them only when deliberately testing alternate
packaging inputs.
packaging inputs. Local bundles also record their git description, which the
About panel and anonymous usage telemetry report in place of the release
version.

Ghosthub bundles kwt CLI helpers but no daemon. A clean `make release-app`
builds the pinned local helper and the Darwin/Linux amd64/arm64 remote matrix
Expand Down
6 changes: 6 additions & 0 deletions docs/release.md
Original file line number Diff line number Diff line change
Expand Up @@ -700,6 +700,12 @@ pinned kwt source. Installation additionally runs the uploaded helper and
requires its first `version` line to report that exact revision before the
helper is promoted into the revisioned remote path.

Local stable bundles record their git description in
`GhosthubDevelopmentVersion`, so the About panel and anonymous usage telemetry
report a development version such as `X.Y.Z-N-gHASH` instead of the
`RELEASE_VERSION` value. Only the release workflow sets `OFFICIAL_RELEASE=1`,
which leaves that key out; do not set it for local builds.

`tools/build_release_dmg.sh` passes kwt overrides to the Makefile only when
they are nonempty. A clean release therefore retains these pinned defaults
instead of overriding `KWT_BINARY_PATH` with an empty value.
Expand Down
2 changes: 1 addition & 1 deletion tools/assemble_app_bundle.py
Original file line number Diff line number Diff line change
Expand Up @@ -115,7 +115,7 @@ def resolve_release_metadata(
return ReleaseMetadata(
display_name,
channel,
None,
development_version,
STABLE_SPARKLE_FEED_URL,
STABLE_SPARKLE_PUBLIC_ED_KEY,
None,
Expand Down
16 changes: 10 additions & 6 deletions tools/development_version.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@
class BundleVersions:
short: str
display: str
# Keeps the commit suffix on a release tag so local builds never pass for one.
local: str


def bundle_versions_from_git_describe(description: str) -> BundleVersions:
Expand All @@ -32,15 +34,17 @@ def bundle_versions_from_git_describe(description: str) -> BundleVersions:
display = tagged["version"]
else:
display = description.removeprefix("v")
return BundleVersions(short=tagged["version"], display=display)
return BundleVersions(
short=tagged["version"],
display=display,
local=description.removeprefix("v"),
)

untagged = UNTAGGED_DESCRIPTION.fullmatch(description)
if untagged:
dirty = untagged["dirty"] or ""
return BundleVersions(
short="0.0.0",
display=f"0.0.0-0-g{untagged['revision']}{dirty}",
)
display = f"0.0.0-0-g{untagged['revision']}{dirty}"
return BundleVersions(short="0.0.0", display=display, local=display)

raise ValueError(f"unsupported git description: {description}")

Expand Down Expand Up @@ -77,7 +81,7 @@ def main() -> None:
)
parser.add_argument(
"--component",
choices=("short", "display"),
choices=("short", "display", "local"),
default="display",
)
arguments = parser.parse_args()
Expand Down
18 changes: 18 additions & 0 deletions tools/tests/test_assemble_app_bundle.py
Original file line number Diff line number Diff line change
Expand Up @@ -367,6 +367,7 @@ def test_release_info_plist_contains_update_configuration(tmp_path):
assert plist["GhosthubKwtVersion"] == "0.1.0"
assert plist["GhosthubKwtSourceRevision"] == "abc123"
assert plist["GhosthubReleaseChannel"] == "stable"
assert "GhosthubDevelopmentVersion" not in plist
assert plist["SUFeedURL"] == assemble.STABLE_SPARKLE_FEED_URL
assert plist["SUPublicEDKey"] == assemble.STABLE_SPARKLE_PUBLIC_ED_KEY
assert plist["SUEnableAutomaticChecks"] is True
Expand Down Expand Up @@ -443,6 +444,23 @@ def test_nightly_info_plist_uses_nightly_update_configuration(tmp_path):
assert plist["SUPublicEDKey"] == nightly_key


def test_local_stable_metadata_keeps_development_version():
assemble = load_module()

metadata = assemble.resolve_release_metadata(
channel=assemble.ReleaseChannel.STABLE,
display_name="Ghosthub",
development_version="0.3.0-72-g3c67741",
nightly_feed_url=None,
nightly_public_key=None,
source_revision=None,
build_date=None,
)

assert metadata.development_version == "0.3.0-72-g3c67741"
assert metadata.feed_url == assemble.STABLE_SPARKLE_FEED_URL


@pytest.mark.parametrize(
("field", "value"),
[
Expand Down
10 changes: 7 additions & 3 deletions tools/tests/test_development_version.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,28 +5,32 @@


@pytest.mark.parametrize(
("description", "expected_short", "expected_display"),
("description", "expected_short", "expected_display", "expected_local"),
[
("v0.3.0-0-g0123abc", "0.3.0", "0.3.0"),
("v0.3.0-0-g0123abc", "0.3.0", "0.3.0", "0.3.0-0-g0123abc"),
(
"v0.3.0-8-g3c67741",
"0.3.0",
"0.3.0-8-g3c67741",
"0.3.0-8-g3c67741",
),
(
"v0.3.0-8-g3c67741-dirty",
"0.3.0",
"0.3.0-8-g3c67741-dirty",
"0.3.0-8-g3c67741-dirty",
),
("3c67741", "0.0.0", "0.0.0-0-g3c67741"),
("3c67741", "0.0.0", "0.0.0-0-g3c67741", "0.0.0-0-g3c67741"),
],
)
def test_version_tracks_the_nearest_release_tag(
description: str,
expected_short: str,
expected_display: str,
expected_local: str,
) -> None:
versions = bundle_versions_from_git_describe(description)

assert versions.short == expected_short
assert versions.display == expected_display
assert versions.local == expected_local
Loading