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
222 changes: 209 additions & 13 deletions .github/workflows/desktop-macos-preview.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2,25 +2,38 @@ name: Desktop macOS Preview

on:
pull_request:
types: [labeled, synchronize, reopened]
types: [labeled, unlabeled, synchronize, reopened, closed]

permissions:
contents: read
pull-requests: write

# Build events and cleanup events use separate groups: a push must cancel a
# stale in-flight build, but must never cancel a cleanup run mid-delete. The
# publish job re-checks PR state before uploading to cover the reverse race.
concurrency:
group: desktop-macos-preview-${{ github.event.pull_request.number }}
cancel-in-progress: true
group: desktop-macos-preview-${{ github.event.pull_request.number }}-${{ contains(fromJSON('["closed", "unlabeled"]'), github.event.action) && 'cleanup' || 'build' }}
# Cleanup runs must complete (a close event right after an unlabel queues
# behind the running cleanup instead of canceling it mid-delete), and events
# that skip the build job, such as adding an unrelated label, must not
# cancel an in-flight build either.
cancel-in-progress: ${{ !contains(fromJSON('["closed", "unlabeled"]'), github.event.action) && (github.event.action != 'labeled' || github.event.label.name == 'preview:mac') }}

jobs:
# Builds run PR code, so this job keeps a read-only token. Publishing to the
# release happens in the publish job below, which never checks out PR code.
build:
name: Build macOS Apple Silicon preview
if: >-
github.event.action != 'closed' &&
github.event.action != 'unlabeled' &&
github.event.pull_request.head.repo.full_name == github.repository &&
contains(github.event.pull_request.labels.*.name, 'preview:mac') &&
(github.event.action != 'labeled' || github.event.label.name == 'preview:mac')
runs-on: blacksmith-12vcpu-macos-26
timeout-minutes: 30
outputs:
dmg_name: ${{ steps.build.outputs.dmg_name }}
version: ${{ steps.version.outputs.version }}
steps:
- name: Checkout
uses: actions/checkout@v6
Expand Down Expand Up @@ -93,8 +106,9 @@ jobs:
fi
printf 'dmg_name=%s\n' "$(basename "${dmg_files[0]}")" >> "$GITHUB_OUTPUT"

- id: upload
name: Upload macOS DMG
# archive: false uploads the file as its own artifact named after the
# file, so the publish job downloads by *.dmg pattern, not by name.
- name: Upload macOS DMG
uses: actions/upload-artifact@v7
with:
path: release/*.dmg
Expand All @@ -103,21 +117,124 @@ jobs:
overwrite: true
retention-days: 7

# Release assets download without a GitHub account, unlike workflow
# artifacts. All preview DMGs live on one rolling prerelease tagged
# "desktop-preview" (release.yml only matches v*.*.* tags), so publishing a
# build never notifies release watchers. This job holds the write token and
# only handles the artifact the build job produced; it never runs PR code.
publish:
name: Publish anonymous download
needs: build
runs-on: blacksmith-8vcpu-ubuntu-2404
timeout-minutes: 10
permissions:
contents: write
pull-requests: write
steps:
- name: Download macOS DMG
uses: actions/download-artifact@v8
with:
pattern: "*.dmg"
merge-multiple: true
path: release

- id: upload
name: Upload DMG to the rolling preview release
shell: bash
env:
GH_TOKEN: ${{ github.token }}
PR_NUMBER: ${{ github.event.pull_request.number }}
DEFAULT_BRANCH: ${{ github.event.repository.default_branch }}
run: |
set -euo pipefail

tag="desktop-preview"

# True while the PR is open and still carries the preview label.
preview_eligible() {
[[ "$(gh pr view "$PR_NUMBER" --repo "$GITHUB_REPOSITORY" \
--json state,labels \
--jq '.state + " " + (.labels | map(.name) | contains(["preview:mac"]) | tostring)')" == "OPEN true" ]]
}

# The build ran for many minutes. If the PR closed or lost the label
# meanwhile, cleanup already ran in its own concurrency group, so
# publishing now would resurrect a deleted download.
if ! preview_eligible; then
echo "PR closed or preview label removed while building. Skipping publish."
exit 0
fi

dmg_path="$(find release -type f -name '*.dmg' -print -quit)"
if [[ -z "$dmg_path" ]]; then
echo "No DMG found in the downloaded artifact." >&2
exit 1
fi

# The filename comes out of the build, which runs PR code. Requiring
# this PR's marker keeps a build from clobbering or deleting another
# PR's asset, since those names carry a different -pr.N. marker.
if [[ "$(basename "$dmg_path")" != *"-pr.${PR_NUMBER}."* ]]; then
echo "DMG name '$(basename "$dmg_path")' does not carry this PR's -pr.${PR_NUMBER}. marker. Refusing to publish." >&2
exit 1
fi

if ! gh release view "$tag" --repo "$GITHUB_REPOSITORY" >/dev/null 2>&1; then
# "|| true" tolerates a concurrent publish job creating the
# release between the check and the create.
gh release create "$tag" \
--repo "$GITHUB_REPOSITORY" \
--target "$DEFAULT_BRANCH" \
--prerelease \
--title "Desktop preview builds" \
--notes "Rolling unsigned desktop builds from pull requests with a preview label. Each download is removed when its pull request closes or loses the label. Install stable builds from the latest release instead." \
|| true
fi

# Keep one DMG per PR: drop this PR's older builds first. The
# trailing dot keeps -pr.12. from matching -pr.123. builds.
gh release view "$tag" --repo "$GITHUB_REPOSITORY" --json assets --jq '.assets[].name' \
| { grep -F -- "-pr.${PR_NUMBER}." || true; } \
| while read -r asset; do
gh release delete-asset "$tag" "$asset" --repo "$GITHUB_REPOSITORY" --yes \
|| echo "Asset $asset was already removed by a concurrent run."
done

gh release upload "$tag" "$dmg_path" --repo "$GITHUB_REPOSITORY" --clobber

# Re-check after uploading. A cleanup run that started during the
# upload listed assets before ours existed, so it cannot delete it.
# Whichever writer acts last sees the final PR state; if the preview
# became ineligible, delete what we just uploaded.
if ! preview_eligible; then
gh release delete-asset "$tag" "$(basename "$dmg_path")" --repo "$GITHUB_REPOSITORY" --yes \
|| echo "Asset was already removed by a concurrent run."
echo "PR closed or preview label removed during upload. Removed the download."
exit 0
fi

echo "download_url=https://github.com/${GITHUB_REPOSITORY}/releases/download/${tag}/$(basename "$dmg_path")" >> "$GITHUB_OUTPUT"

- name: Comment download link
if: steps.upload.outputs.download_url != ''
uses: actions/github-script@v8
env:
ARTIFACT_URL: ${{ steps.upload.outputs.artifact-url }}
DMG_NAME: ${{ steps.build.outputs.dmg_name }}
DOWNLOAD_URL: ${{ steps.upload.outputs.download_url }}
DMG_NAME: ${{ needs.build.outputs.dmg_name }}
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
PREVIEW_VERSION: ${{ steps.version.outputs.version }}
PREVIEW_VERSION: ${{ needs.build.outputs.version }}
with:
script: |
const { data: pullRequest } = await github.rest.pulls.get({
owner: context.repo.owner,
repo: context.repo.repo,
pull_number: context.payload.pull_request.number,
});
if (pullRequest.head.sha !== process.env.HEAD_SHA) {
if (
pullRequest.head.sha !== process.env.HEAD_SHA ||
pullRequest.state !== "open" ||
!pullRequest.labels.some((label) => label.name === "preview:mac")
) {
core.info("Skipping the outdated macOS preview comment.");
return;
}
Expand All @@ -127,7 +244,7 @@ jobs:
marker,
"### macOS preview",
"",
`[Download Apple Silicon DMG](${process.env.ARTIFACT_URL})`,
`[Download Apple Silicon DMG](${process.env.DOWNLOAD_URL})`,
"",
`Version: ${process.env.PREVIEW_VERSION}`,
`Commit: ${process.env.HEAD_SHA.slice(0, 7)}`,
Expand All @@ -137,10 +254,10 @@ jobs:
`xattr -d com.apple.quarantine ~/Downloads/${process.env.DMG_NAME}`,
"```",
"",
"The download requires GitHub access and expires after 7 days.",
"No GitHub sign-in is needed. The download stays available until this PR closes or the preview label is removed.",
].join("\n");

const { data: comments } = await github.rest.issues.listComments({
const comments = await github.paginate(github.rest.issues.listComments, {
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.payload.pull_request.number,
Expand All @@ -163,3 +280,82 @@ jobs:
body,
});
}

# The way out: closing the PR or removing the label deletes its DMG from the
# rolling release and updates the PR comment to say so.
cleanup:
name: Remove preview download
if: >-
github.event.pull_request.head.repo.full_name == github.repository &&
((github.event.action == 'closed' && contains(github.event.pull_request.labels.*.name, 'preview:mac')) ||
(github.event.action == 'unlabeled' && github.event.label.name == 'preview:mac'))
runs-on: blacksmith-8vcpu-ubuntu-2404
timeout-minutes: 10
permissions:
contents: write
pull-requests: write
steps:
- id: delete
name: Delete this PR's preview assets
shell: bash
env:
GH_TOKEN: ${{ github.token }}
PR_NUMBER: ${{ github.event.pull_request.number }}
run: |
set -euo pipefail

tag="desktop-preview"

# A stale cleanup must not delete a download that became valid
# again. If the PR is open and labeled once more, the next publish
# owns this PR's assets and replaces them itself.
if [[ "$(gh pr view "$PR_NUMBER" --repo "$GITHUB_REPOSITORY" \
--json state,labels \
--jq '.state + " " + (.labels | map(.name) | contains(["preview:mac"]) | tostring)')" == "OPEN true" ]]; then
echo "PR is open and labeled again. Skipping cleanup."
echo "removed=false" >> "$GITHUB_OUTPUT"
exit 0
fi

echo "removed=true" >> "$GITHUB_OUTPUT"

if ! gh release view "$tag" --repo "$GITHUB_REPOSITORY" >/dev/null 2>&1; then
echo "No preview release exists. Nothing to clean up."
exit 0
fi

gh release view "$tag" --repo "$GITHUB_REPOSITORY" --json assets --jq '.assets[].name' \
| { grep -F -- "-pr.${PR_NUMBER}." || true; } \
| while read -r asset; do
gh release delete-asset "$tag" "$asset" --repo "$GITHUB_REPOSITORY" --yes \
|| echo "Asset $asset was already removed by a concurrent run."
done

- name: Mark the preview comment as removed
if: steps.delete.outputs.removed == 'true'
uses: actions/github-script@v8
with:
script: |
const marker = "<!-- desktop-macos-preview -->";
const comments = await github.paginate(github.rest.issues.listComments, {
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.payload.pull_request.number,
per_page: 100,
});
const existing = comments.find((comment) => comment.body?.includes(marker));
if (!existing) {
return;
}

await github.rest.issues.updateComment({
owner: context.repo.owner,
repo: context.repo.repo,
comment_id: existing.id,
body: [
marker,
"### macOS preview",
"",
"The preview download was removed because this PR closed or the preview label was removed.",
].join("\n"),
});
13 changes: 13 additions & 0 deletions apps/mobile/src/features/threads/threadListV2.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -304,6 +304,19 @@ describe("sortThreadsForListV2", () => {
]);
expect(sorted.map((thread) => thread.id)).toEqual(["newest", "middle", "oldest"]);
});

it("surfaces an un-settled thread at the top via its re-entry stamp", () => {
const sorted = sortThreadsForListV2([
{
id: "old-unsettled",
createdAt: "2026-06-01T08:00:00.000Z",
unsettledAt: "2026-06-01T13:00:00.000Z",
},
{ id: "newest", createdAt: "2026-06-01T12:00:00.000Z" },
{ id: "middle", createdAt: "2026-06-01T10:00:00.000Z" },
]);
expect(sorted.map((thread) => thread.id)).toEqual(["old-unsettled", "newest", "middle"]);
});
});

describe("buildThreadListV2Items", () => {
Expand Down
27 changes: 18 additions & 9 deletions apps/mobile/src/features/threads/threadListV2.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,10 @@ import type {
} from "@t3tools/client-runtime/state/thread-settled";
import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell";
import { threadSearchMatchKey } from "@t3tools/client-runtime/state/thread-search";
import { sortPinnedThreadsByOrderKey } from "@t3tools/client-runtime/state/thread-sort";
import {
activeThreadAnchorTimestampMs,
sortPinnedThreadsByOrderKey,
} from "@t3tools/client-runtime/state/thread-sort";
import type { EnvironmentId, ProjectId, ThreadLinkedPullRequest } from "@t3tools/contracts";

import type { PendingNewTask } from "../../state/use-pending-new-tasks";
Expand Down Expand Up @@ -191,19 +194,25 @@ function firstValidTimestampMs(...candidates: ReadonlyArray<string | null | unde
}

/**
* v2 sort: static creation order, newest thread on top. Activity NEVER
* reorders the list — a row holds its position from open until settled, so
* the screen only moves at lifecycle transitions. Mirrors web's
* sortThreadsForSidebarV2.
* v2 sort: static order, newest anchor on top. Activity NEVER reorders the
* list — a row holds its position between lifecycle transitions. The anchor
* is creation time until an un-settle re-anchors it (see
* activeThreadAnchorTimestampMs), so an un-settled thread surfaces at the
* top instead of sinking back to its creation-order slot. Mirrors web's
* sortThreadsForSidebar.
*/
export function sortThreadsForListV2<T extends { readonly id: string; readonly createdAt: string }>(
threads: readonly T[],
): T[] {
export function sortThreadsForListV2<
T extends {
readonly id: string;
readonly createdAt: string;
readonly unsettledAt?: string | null | undefined;
},
>(threads: readonly T[]): T[] {
// .sort() on a copy, not .toSorted(): Hermes doesn't ship the ES2023
// change-by-copy array methods.
return [...threads].sort(
(left, right) =>
parseTimestampMs(right.createdAt) - parseTimestampMs(left.createdAt) ||
activeThreadAnchorTimestampMs(right) - activeThreadAnchorTimestampMs(left) ||
left.id.localeCompare(right.id),
);
}
Expand Down
Loading
Loading