diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index dcff5ec2..673e9c13 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -42,6 +42,21 @@ jobs: output = subprocess.check_output(command, text=True).strip() return [line for line in output.splitlines() if line] + alpha_pattern = re.compile(r"^alpha-v(\d{4}\.\d{1,2}\.\d{1,2})-alpha\.(\d+)$") + + def parse_alpha_tag(tag: str) -> tuple[str, int] | None: + match = alpha_pattern.fullmatch(tag) + if not match: + return None + calendar_version, sequence = match.groups() + return calendar_version, int(sequence) + + def alpha_version(calendar_version: str, sequence: int) -> str: + return f"{calendar_version}-alpha.{sequence}" + + def alpha_tag(calendar_version: str, sequence: int) -> str: + return f"alpha-v{calendar_version}-alpha.{sequence:04d}" + existing_tags = [ tag for tag in lines(["git", "tag", "--points-at", "HEAD"]) if tag.startswith("alpha-v") @@ -49,7 +64,8 @@ jobs: if existing_tags: tag = existing_tags[0] - version = tag.removeprefix("alpha-v") + parsed = parse_alpha_tag(tag) + version = alpha_version(*parsed) if parsed is not None else tag.removeprefix("alpha-v") else: today = datetime.now(timezone.utc).date() stable_date = None @@ -71,8 +87,8 @@ jobs: calendar_version = f"{alpha_date.year}.{alpha_date.month}.{alpha_date.day}" sequence = len(lines(["git", "tag", "--list", f"alpha-v{calendar_version}-alpha.*"])) + 1 - version = f"{calendar_version}-alpha.{sequence}" - tag = f"alpha-v{version}" + version = alpha_version(calendar_version, sequence) + tag = alpha_tag(calendar_version, sequence) display_match = re.fullmatch(r"(\d{4})\.(\d{1,2})\.(\d{1,2})-alpha\.(\d+)", version) display_version = ( @@ -275,7 +291,27 @@ jobs: - name: Generate release notes run: | - PREV_TAG=$(git tag --list 'alpha-v*' --sort=-version:refname | head -n 1 || echo "") + PREV_TAG=$(python3 <<'PY' + import re + import subprocess + + current_tag = '${{ needs.version.outputs.tag }}' + pattern = re.compile(r'^alpha-v(\d{4})\.(\d{1,2})\.(\d{1,2})-alpha\.(\d+)$') + + output = subprocess.check_output(['git', 'tag', '--list', 'alpha-v*'], text=True).strip() + tags = [line for line in output.splitlines() if line and line != current_tag] + + parsed_tags = [] + for tag in tags: + match = pattern.fullmatch(tag) + if not match: + continue + year, month, day, sequence = map(int, match.groups()) + parsed_tags.append(((year, month, day, sequence), tag)) + + print(max(parsed_tags)[1] if parsed_tags else '') + PY + ) if [ -z "$PREV_TAG" ]; then NOTES=$(git log --oneline --no-merges -20) else diff --git a/docs/ABSTRACTIONS.md b/docs/ABSTRACTIONS.md index 2d89716d..e2d3b20d 100644 --- a/docs/ABSTRACTIONS.md +++ b/docs/ABSTRACTIONS.md @@ -669,6 +669,6 @@ Managed by `useSettings` hook and `SettingsPanel` component. `default_ai_agent` - **`download_and_install_app_update`** — Channel-aware download/install with streamed progress events. ### CI/CD -- **`.github/workflows/release.yml`** — Alpha prereleases from every push to `main` using calendar-semver technical versions (`YYYY.M.D-alpha.N`) and clean `Alpha YYYY.M.D.N` release names. Publishes `alpha/latest.json` and refreshes the legacy `latest.json` / `latest-canary.json` aliases to the alpha feed. +- **`.github/workflows/release.yml`** — Alpha prereleases from every push to `main` using calendar-semver technical versions (`YYYY.M.D-alpha.N`) and clean `Alpha YYYY.M.D.N` release names. GitHub alpha tags zero-pad the prerelease sequence (`alpha-vYYYY.M.D-alpha.NNNN`) so GitHub release ordering stays chronological while the shipped app version remains `YYYY.M.D-alpha.N`. Publishes `alpha/latest.json` and refreshes the legacy `latest.json` / `latest-canary.json` aliases to the alpha feed. - **`.github/workflows/release-stable.yml`** — Stable releases from `stable-vYYYY.M.D` tags. Publishes `stable/latest.json`. - **Beta cohorts** are handled in PostHog targeting only. There is no beta updater feed. diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 5e0a36d0..4964c774 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -782,6 +782,7 @@ Every push to `main` triggers `.github/workflows/release.yml`: ``` push to main → version job: compute calendar alpha version YYYY.M.D-alpha.N + and a GitHub-sorted tag alpha-vYYYY.M.D-alpha.NNNN → use today's UTC date unless the latest stable-vYYYY.M.D tag already uses today → if stable already uses today, advance alpha to the next calendar day so semver still increases → build job: @@ -789,7 +790,7 @@ push to main → upload signed .app.tar.gz + .sig updater artifacts → release job: → generate alpha-latest.json - → publish GitHub prerelease alpha-v named Tolaria Alpha YYYY.M.D.N + → publish GitHub prerelease alpha-vYYYY.M.D-alpha.NNNN named Tolaria Alpha YYYY.M.D.N → pages job: → build static HTML release history page → publish alpha/latest.json @@ -819,7 +820,7 @@ push stable-vYYYY.M.D tag ### Versioning - Stable promotions use git tags in the form `stable-vYYYY.M.D` and stamp the technical version `YYYY.M.D`. -- Alpha builds stamp the technical version `YYYY.M.D-alpha.N` and display it as `Alpha YYYY.M.D.N`. +- Alpha builds stamp the technical version `YYYY.M.D-alpha.N` and display it as `Alpha YYYY.M.D.N`. The GitHub release tag zero-pads the sequence as `alpha-vYYYY.M.D-alpha.NNNN` so GitHub release ordering remains chronological. - If the latest stable tag already uses today's date, alpha advances to the next calendar day before assigning `-alpha.N` so Alpha remains semver-newer than Stable across channel switches. - The workflows stamp the computed version into `tauri.conf.json` and `Cargo.toml` at build time. - This keeps display strings clean while preserving semver monotonicity when a user switches between Stable and Alpha. diff --git a/src/utils/releaseHistoryPage.test.ts b/src/utils/releaseHistoryPage.test.ts index 61d0ca7e..6971efb3 100644 --- a/src/utils/releaseHistoryPage.test.ts +++ b/src/utils/releaseHistoryPage.test.ts @@ -60,6 +60,35 @@ describe('buildReleaseHistoryPage', () => { expect(html).toContain('

First paragraph
with a line break.

Second paragraph

') }) + it('sorts releases within each channel by published date descending even when the payload order is wrong', () => { + const html = buildReleaseHistoryPage([ + { + body: 'Older alpha release', + name: 'Tolaria Alpha 2026.4.20.9', + prerelease: true, + published_at: '2026-04-20T09:44:02Z', + tag_name: 'alpha-v2026.4.20-alpha.9', + }, + { + body: 'Newest alpha release', + name: 'Tolaria Alpha 2026.4.20.12', + prerelease: true, + published_at: '2026-04-20T16:53:41Z', + tag_name: 'alpha-v2026.4.20-alpha.12', + }, + { + body: 'Middle alpha release', + name: 'Tolaria Alpha 2026.4.20.10', + prerelease: true, + published_at: '2026-04-20T10:32:01Z', + tag_name: 'alpha-v2026.4.20-alpha.10', + }, + ]) + + expect(html.indexOf('Tolaria Alpha 2026.4.20.12')).toBeLessThan(html.indexOf('Tolaria Alpha 2026.4.20.10')) + expect(html.indexOf('Tolaria Alpha 2026.4.20.10')).toBeLessThan(html.indexOf('Tolaria Alpha 2026.4.20.9')) + }) + it('filters draft releases and shows an empty state for channels without published builds', () => { const html = buildReleaseHistoryPage([ { diff --git a/src/utils/releaseHistoryPage.ts b/src/utils/releaseHistoryPage.ts index 4f126748..1b7fe6d1 100644 --- a/src/utils/releaseHistoryPage.ts +++ b/src/utils/releaseHistoryPage.ts @@ -27,6 +27,7 @@ type ReleaseEntry = { githubUrl: string | null notesHtml: string publishedLabel: string + publishedTimestamp: number tagName: string title: string } @@ -419,6 +420,15 @@ function formatPublishedLabel(value: unknown): string { }) } +function parsePublishedTimestamp(value: unknown): number { + const text = normalizeText(value) + if (text === null) return Number.NEGATIVE_INFINITY + + const publishedAt = new Date(text) + const timestamp = publishedAt.getTime() + return Number.isNaN(timestamp) ? Number.NEGATIVE_INFINITY : timestamp +} + function isDownloadableAsset(name: string): boolean { return name.endsWith('.dmg') || name.endsWith('.app.tar.gz') || name.endsWith('.zip') } @@ -471,6 +481,7 @@ function normalizeReleaseEntry(release: GitHubReleasePayload): [ReleaseChannel, githubUrl: normalizeUrl(release.html_url), notesHtml: resolveReleaseNotesHtml(release.body_html, release.body), publishedLabel: formatPublishedLabel(release.published_at), + publishedTimestamp: parsePublishedTimestamp(release.published_at), tagName, title, }] @@ -490,6 +501,10 @@ function collectReleaseSections(payload: unknown): ReleaseSections { sections[channel].push(release) } + for (const channel of ['stable', 'alpha'] as const) { + sections[channel].sort((left, right) => right.publishedTimestamp - left.publishedTimestamp) + } + return sections }