From 3b1017c4fbd9a3e2cbac66feac35d5c6b5342da2 Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Sun, 9 Aug 2026 19:01:35 -0700 Subject: [PATCH] Add nightly cut (#13410) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Add daily macOS dev build release channel Publish once-daily signed macOS builds from main at a dedicated cadence, separate from hourly (too noisy) and release branches (too infrequent). Builds are notarized and installable via the updater, but unvetted — published to stablyai/orca-daily rather than the main repo to avoid evicting stable/RC entries from the releases feed. * fix lint * fix commit * Add third token mint to daily macOS build workflow The upload step's 2x45m retry budget can outlive the one-hour token, so a third is minted after it for verify and cleanup operations. Release notes are moved to a file to ensure consistency between draft creation and publish. Daily channel description updated with specific UTC release time. --- .github/workflows/daily-mac-build.yml | 471 ++++++++++++++++++ config/electron-builder.config.cjs | 25 +- config/scripts/daily-build-version.mjs | 109 ++++ config/scripts/daily-build-version.test.mjs | 111 +++++ .../scripts/electron-builder-config.test.mjs | 119 ----- ...ectron-builder-mac-channel-config.test.mjs | 152 ++++++ config/scripts/setup-adhoc-release-repo.sh | 2 +- config/scripts/setup-daily-release-repo.sh | 98 ++++ config/scripts/setup-hourly-release-token.sh | 7 +- src/main/updater-release-builds.test.ts | 28 ++ src/main/updater.test.ts | 1 + .../settings/ReleaseChannelSection.tsx | 35 +- src/renderer/src/i18n/locales/en.json | 1 + src/shared/release-channel.test.ts | 44 +- src/shared/release-channel.ts | 61 ++- 15 files changed, 1106 insertions(+), 158 deletions(-) create mode 100644 .github/workflows/daily-mac-build.yml create mode 100755 config/scripts/daily-build-version.mjs create mode 100644 config/scripts/daily-build-version.test.mjs create mode 100644 config/scripts/electron-builder-mac-channel-config.test.mjs create mode 100755 config/scripts/setup-daily-release-repo.sh diff --git a/.github/workflows/daily-mac-build.yml b/.github/workflows/daily-mac-build.yml new file mode 100644 index 000000000..5670c634d --- /dev/null +++ b/.github/workflows/daily-mac-build.yml @@ -0,0 +1,471 @@ +name: Daily macOS Dev Build + +# Why: gives developers a signed macOS build of main once a day that the in-app +# updater can install directly, without waiting for an RC cut — less noise than +# hourly. +# +# Schedule is a single UTC cron (GH Actions has no timezone-aware schedules). +# 14:15 UTC is early morning Pacific year-round (6:15am PST / 7:15am PDT). Minute +# 15 avoids stacking with hourly-mac-build, which fires at minute 0 every hour. +# +# Deliberately narrow scope: +# - macOS only. Other platforms keep using RC/stable. +# - No tests, no lint, no e2e. This channel trades safety for latency; PR CI +# and release-cut remain the gates that matter. +# - Signed AND notarized, exactly like a release. The notary round trip is the +# one slow step kept: macOS anchors a notarized app's TCC grants on identifier +# + team rather than on its cdhash, so those grants survive an update. Without +# a ticket every daily reads as a new client and silently loses file access +# under Documents/Desktop/Downloads. +# +# Artifacts publish to stablyai/orca-daily, never to stablyai/orca: the main +# repo's releases atom feed exposes only its 10 newest entries, so high-volume +# dev tags would evict every stable/RC entry and break updates for real users. +# Separate from orca-hourly so someone riding main's hourlies never sees the +# sparse daily series mixed into that list. +# +# GITHUB_TOKEN is scoped to this repo and cannot publish there, so writes use the +# same GitHub App as hourly (installed on orca-daily with Contents: Read and +# write). Provision the App secrets with +# `bash config/scripts/setup-hourly-release-token.sh`, then grant the App access +# to orca-daily with `bash config/scripts/setup-daily-release-repo.sh`: +# HOURLY_RELEASE_APP_ID the App's numeric id (shared with hourly/adhoc) +# HOURLY_RELEASE_APP_PRIVATE_KEY the App's .pem private key +# +# Installation tokens live one hour, which is why this mints three times. Install +# and build need no token at all, and notarization can hold the publish step for +# tens of minutes; minting again once the build is done starts the clock at the +# first call that actually uses it rather than burning a third of it on +# `pnpm install`. The upload step's own retry budget (2x45m) can outlive that +# second token, so a third is minted after it for verify/publish/prune/cleanup — +# without it a stuck notary run would strand a draft that cleanup gets a 401 on. +# The release stays an unpublished draft until the manifest check passes, so the +# worst case is still an invisible draft — and the build-number query counts +# drafts, so it holds its number and the next run does not reuse it. + +on: + schedule: + # Once a day, early morning Pacific. Single cron — no DST twin, no clock gate. + - cron: '15 14 * * *' + workflow_dispatch: + inputs: + force: + description: Build even if main has not moved since the last daily + required: false + default: false + type: boolean + +permissions: + contents: read + +concurrency: + group: daily-mac-build + cancel-in-progress: false + +env: + DAILY_REPO: stablyai/orca-daily + # Keep ~30 days so a regression can be bisected without holding a full year. + DAILY_RETAIN_COUNT: 30 + +jobs: + build-daily-mac: + if: github.repository == 'stablyai/orca' + runs-on: blacksmith-6vcpu-macos-15 + # Why 150: it must exceed the worst case the retry budgets below can produce + # (install 3x10 + publish 2x45 = 120, plus ~25 for checkout/build/verify), or + # the job is killed mid-retry and no cleanup step runs at all. A typical run + # is far shorter — this is the notary queue's tail, not its median. + timeout-minutes: 150 + env: + NODE_OPTIONS: --max-old-space-size=4096 + steps: + - name: Checkout + uses: actions/checkout@v6 + with: + ref: main + fetch-depth: 0 + # Why: this job only reads stablyai/orca and never pushes; every write + # goes to the daily repo through a minted App token passed by env. + # Not persisting the checkout credential shrinks the blast radius if a + # build step is compromised (zizmor: artipacked). + persist-credentials: false + + - name: Mint daily repo token + id: app_token + uses: actions/create-github-app-token@v2 + with: + app-id: ${{ secrets.HOURLY_RELEASE_APP_ID }} + private-key: ${{ secrets.HOURLY_RELEASE_APP_PRIVATE_KEY }} + owner: stablyai + repositories: orca-daily + + # Why: main is often idle. Rebuilding an unchanged commit burns a runner + # hour and adds a redundant tag to the retention window. + - name: Check whether main moved since the last daily + id: freshness + shell: bash + env: + GH_TOKEN: ${{ steps.app_token.outputs.token }} + FORCED: ${{ github.event_name == 'workflow_dispatch' && inputs.force }} + run: | + set -euo pipefail + head_sha="$(git rev-parse HEAD)" + echo "head_sha=$head_sha" >>"$GITHUB_OUTPUT" + if [[ "$FORCED" == "true" ]]; then + echo "should_build=true" >>"$GITHUB_OUTPUT" + echo "Forced dispatch; building $head_sha." + exit 0 + fi + # The previous daily records its source commit in the release body. + # Drafts are excluded: an unpublished leftover never shipped, so treating + # it as "the last build" would skip a build that never actually happened. + last_body="$(gh release list --repo "$DAILY_REPO" --limit 20 --json tagName,isDraft \ + --jq 'map(select(.isDraft | not)) | .[0].tagName // empty' 2>/dev/null || true)" + if [[ -z "$last_body" ]]; then + echo "should_build=true" >>"$GITHUB_OUTPUT" + echo "No prior daily release found; building $head_sha." + exit 0 + fi + last_sha="$(gh release view "$last_body" --repo "$DAILY_REPO" --json body \ + --jq '.body | capture("commit `(?[0-9a-f]{7,40})`") | .sha' 2>/dev/null || true)" + if [[ -n "$last_sha" && "$head_sha" == "$last_sha"* ]]; then + echo "should_build=false" >>"$GITHUB_OUTPUT" + echo "main is unchanged since $last_body ($last_sha); skipping." + else + echo "should_build=true" >>"$GITHUB_OUTPUT" + echo "main moved to $head_sha (last daily built $last_sha); building." + fi + + - name: Setup pnpm + if: steps.freshness.outputs.should_build == 'true' + uses: pnpm/action-setup@v6 + with: + run_install: false + + - name: Setup Node.js + if: steps.freshness.outputs.should_build == 'true' + uses: actions/setup-node@v6 + with: + node-version-file: package.json + cache: pnpm + + - name: Cache electron-builder downloads + if: steps.freshness.outputs.should_build == 'true' + uses: actions/cache@v5 + with: + path: | + ~/Library/Caches/electron + ~/Library/Caches/electron-builder + key: electron-builder-mac-${{ hashFiles('pnpm-lock.yaml') }} + restore-keys: | + electron-builder-mac- + + - name: Install dependencies + if: steps.freshness.outputs.should_build == 'true' + uses: nick-fields/retry@v4 + with: + timeout_minutes: 10 + max_attempts: 3 + retry_wait_seconds: 30 + command: pnpm install --frozen-lockfile + + # Why: signing is what makes a daily installable over an existing Orca, so + # a missing cert must fail here rather than after a 20-minute build. + - name: Verify macOS signing environment + if: steps.freshness.outputs.should_build == 'true' + run: node config/scripts/verify-macos-release-env.mjs + env: + CSC_LINK: ${{ secrets.MAC_CERTS }} + CSC_KEY_PASSWORD: ${{ secrets.MAC_CERTS_PASSWORD }} + APPLE_ID: ${{ secrets.APPLE_ID }} + APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }} + APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} + + - name: Compute daily version + id: daily + if: steps.freshness.outputs.should_build == 'true' + shell: bash + env: + GH_TOKEN: ${{ steps.app_token.outputs.token }} + MAIN_REPO_TOKEN: ${{ github.token }} + run: | + set -euo pipefail + # Existing titles, which carry the build number this series continues + # from. The script picks the number, because it restarts per base version + # and only the script knows which base this build resolved to. + # + # Why drafts count here but not in the freshness check: that check asks + # "did this commit ship", where a draft is a no. This one asks "is the + # number free", where a stranded draft still holds one. + names="$(gh release list --repo "$DAILY_REPO" --limit 200 --json name \ + --jq '.[].name // empty')" + # Why the main repo's tags decide the base version rather than + # package.json: main's version only moves on `release:` commits, and + # stable patches are cut from release branches that never merge back, so + # package.json can sit several patches behind what users are running. A + # separate token because GH_TOKEN above is the App's, scoped to the + # daily repo. Empty on failure — the script then falls back to + # package.json, which is stale but never wrong enough to fail a build. + published="$(GH_TOKEN="$MAIN_REPO_TOKEN" gh release list \ + --repo "$GITHUB_REPOSITORY" --limit 100 --exclude-drafts \ + --json tagName --jq '.[].tagName' || true)" + echo "Highest published tag seen: $(head -1 <<<"$published")" + ORCA_PUBLISHED_VERSIONS="$published" ORCA_DAILY_RELEASE_NAMES="$names" \ + node config/scripts/daily-build-version.mjs \ + >"$RUNNER_TEMP/daily-identity.txt" + grep -E '^(version|build_number)=' "$RUNNER_TEMP/daily-identity.txt" + # Why check rather than trust: the checkout above pins `ref: main`, but a + # workflow_dispatch runs this file from whatever branch was dispatched. A + # branch that edits this step while main still has the old script yields + # an empty name and an untitled release — silent, and only visible once + # someone opens the releases page. Fail here instead. + if ! grep -q '^name=' "$RUNNER_TEMP/daily-identity.txt"; then + echo "::error::daily-build-version.mjs emitted no release name; this workflow and main's copy of the script are out of sync." + exit 1 + fi + cat "$RUNNER_TEMP/daily-identity.txt" >>"$GITHUB_OUTPUT" + + - name: Build app + if: steps.freshness.outputs.should_build == 'true' + run: pnpm build:release + env: + NODE_OPTIONS: --max-old-space-size=4096 + # Why: daily builds are not an official channel — telemetry's transport + # gate accepts only 'stable' or 'rc', so leaving this unset keeps them + # silent, which is correct for unvetted dev artifacts. + ORCA_DIAGNOSTICS_TOKEN_URL: https://www.onorca.dev/diagnostics/token + + # Why a second mint: everything from here on writes to the daily repo, and + # the notary round trip inside the publish step can be tens of minutes. The + # token minted at the top has already spent install + build of its one hour + # on steps that never touched it; restarting the clock here gives the slow + # part the full budget. + - name: Re-mint daily repo token for publish + id: app_token_publish + if: steps.freshness.outputs.should_build == 'true' + uses: actions/create-github-app-token@v2 + with: + app-id: ${{ secrets.HOURLY_RELEASE_APP_ID }} + private-key: ${{ secrets.HOURLY_RELEASE_APP_PRIVATE_KEY }} + owner: stablyai + repositories: orca-daily + + - name: Create daily release + id: release + if: steps.freshness.outputs.should_build == 'true' + shell: bash + env: + GH_TOKEN: ${{ steps.app_token_publish.outputs.token }} + TAG: v${{ steps.daily.outputs.version }} + NAME: ${{ steps.daily.outputs.name }} + SHA: ${{ steps.freshness.outputs.head_sha }} + run: | + set -euo pipefail + # Kept at 12 even though the title shows 7: the freshness check above + # parses this back out of the body to decide whether main has moved. + short_sha="${SHA:0:12}" + # Why a file rather than an inline string: the publish step re-asserts + # this same body, and the freshness check only works if the two agree + # exactly. One source, written once, read twice. + notes_file="$RUNNER_TEMP/daily-release-notes.md" + cat >"$notes_file" <>"$GITHUB_OUTPUT" + echo "notes_file=$notes_file" >>"$GITHUB_OUTPUT" + + - name: Publish daily macOS artifacts + if: steps.freshness.outputs.should_build == 'true' + uses: nick-fields/retry@v4 + with: + # Why 45 like the release pipeline: an attempt is pack + notarize + + # upload, and the notary queue is the unbounded part. Why 2 attempts and + # not 3: a missed daily costs a day, and a third attempt buys less than + # it costs in runner time once the notary is that stuck. + timeout_minutes: 45 + max_attempts: 2 + retry_wait_seconds: 30 + command: node config/scripts/ensure-native-runtime.mjs --runtime=electron && ORCA_MAC_DAILY=1 pnpm exec electron-builder --config config/electron-builder.config.cjs --mac --publish always + env: + # Why: electron-builder's github publisher targets the repo named in the + # config; the token must therefore carry write access to orca-daily. + GH_TOKEN: ${{ steps.app_token_publish.outputs.token }} + ORCA_DAILY_BUILD_VERSION: ${{ steps.daily.outputs.version }} + ORCA_BUILD_COMMIT: ${{ steps.daily.outputs.commit }} + CSC_LINK: ${{ secrets.MAC_CERTS }} + CSC_KEY_PASSWORD: ${{ secrets.MAC_CERTS_PASSWORD }} + # Why all three: electron-builder's notarize step authenticates to the + # Apple notary service with the app-specific password, not with the + # signing cert. Omitting them fails the build rather than skipping it, + # since `notarize` is now on for this path. + APPLE_ID: ${{ secrets.APPLE_ID }} + APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }} + APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} + + # Why a third mint: the upload step above allows two 45-minute attempts, so + # it can outlive the one-hour token minted before it. Every remaining step + # writes to the daily repo, including the failure path that discards the + # draft — a 401 there is exactly the stranded draft nobody can clean up. + # + # Why always(): a failed or cancelled upload is the case that needs this + # most, since the cleanup step below runs only on that path. + - name: Re-mint daily repo token for verify and cleanup + id: app_token_final + if: always() && steps.freshness.outputs.should_build == 'true' + uses: actions/create-github-app-token@v2 + with: + app-id: ${{ secrets.HOURLY_RELEASE_APP_ID }} + private-key: ${{ secrets.HOURLY_RELEASE_APP_PRIVATE_KEY }} + owner: stablyai + repositories: orca-daily + + # Why: the updater resolves a tag, then fetches latest-mac.yml from it. A + # release missing that manifest is a tag the picker offers and the download + # 404s on, so fail loudly instead of leaving a broken entry. + - name: Verify update manifest published + if: steps.freshness.outputs.should_build == 'true' + shell: bash + env: + GH_TOKEN: ${{ steps.app_token_final.outputs.token }} + TAG: ${{ steps.release.outputs.tag }} + run: | + set -euo pipefail + assets="$(gh release view "$TAG" --repo "$DAILY_REPO" --json assets --jq '.assets[].name')" + echo "Published assets:" + echo "$assets" + # Why exit 1 without deleting here: the release is still a draft, so it is + # already invisible to users, and the failure handler below owns cleanup. + # Deleting inline under `set -e` would also let the delete's exit code + # preempt this explicit failure. + for required in latest-mac.yml; do + if ! grep -qx "$required" <<<"$assets"; then + echo "::error::Daily draft $TAG is missing $required; the updater could not install it." + exit 1 + fi + done + if ! grep -q '\.zip$' <<<"$assets"; then + echo "::error::Daily draft $TAG has no ZIP artifact for the updater to download." + exit 1 + fi + + # Why this is the last mutating step: publishing the draft is what makes the + # build visible to listReleaseBuilds. Doing it only after the manifest check + # means the picker can never offer a release whose assets are incomplete. + - name: Publish the verified release + id: publish_live + if: steps.freshness.outputs.should_build == 'true' + shell: bash + env: + GH_TOKEN: ${{ steps.app_token_final.outputs.token }} + TAG: ${{ steps.release.outputs.tag }} + NAME: ${{ steps.daily.outputs.name }} + NOTES_FILE: ${{ steps.release.outputs.notes_file }} + run: | + set -euo pipefail + # --title again: electron-builder resolves this draft by tag and may + # rewrite its title on upload. Re-asserting here means the name the + # picker reads is the one composed above, whatever it did in between. + # --notes-file for the same reason, and it matters more: the next run's + # freshness check parses the source commit back out of this body, so a + # body the publisher overwrote would rebuild an unchanged main daily. + gh release edit "$TAG" --repo "$DAILY_REPO" --draft=false --prerelease \ + --title "$NAME" --notes-file "$NOTES_FILE" + echo "Published $TAG as \"$NAME\"" + + # Why: a draft left behind by a failed publish is invisible to users but still + # holds its tag name, so the next run for the same minute would collide. + # + # Why it is gated on publish_live not having succeeded: a later failure (the + # prune step) must not delete a release that already went live and that users + # may already be installing. A job killed by the outer timeout runs no steps + # at all — which is exactly why the release stays a draft until verified. + # Why cancelled() too: a run stopped from the Actions UI is not a failure(), + # so without it a manual cancel mid-publish would strand the draft. + - name: Discard the draft release on failure + if: >- + (failure() || cancelled()) && steps.release.outputs.tag != '' && + steps.publish_live.outcome != 'success' + shell: bash + env: + # Fall back to the publish token: if the re-mint itself is what failed, + # the older token is the only one left and may still have time on it. + GH_TOKEN: ${{ steps.app_token_final.outputs.token || steps.app_token_publish.outputs.token }} + TAG: ${{ steps.release.outputs.tag }} + run: | + set -uo pipefail + # No --cleanup-tag: an unpublished draft never created a git tag. + echo "Run failed before publish; discarding draft $TAG" + gh release delete "$TAG" --repo "$DAILY_REPO" --yes || + echo "::warning::Could not discard draft $TAG; remove it manually." + + - name: Prune old daily releases + # Only after a live publish: $TAG is then a non-draft we must not delete, + # and failed runs should not reshuffle retention around a draft that the + # failure path is about to discard. + if: steps.publish_live.outcome == 'success' + shell: bash + env: + GH_TOKEN: ${{ steps.app_token_final.outputs.token }} + # Protect the tag this run just shipped; at the retain cap, a bad sort + # can otherwise mark the newest release as stale and delete it. + TAG: ${{ steps.release.outputs.tag }} + run: | + set -euo pipefail + # Why: --cleanup-tag so pruning does not leave orphan tags behind that + # keep showing up in tag lists with no release or assets attached. + # Drafts are excluded so retention counts shipped builds only; a stale + # draft is handled by the failure path, not by the retention window. + # + # Sort by publishedAt (not createdAt). Many non-draft dailies can share + # one createdAt (bulk import / re-create), so createdAt ranking is + # unstable. publishedAt is real recency; tagName (...YYYYMMDDHHMM) is + # the deterministic tie-break. + # + # Why force $TAG to the front before slicing: a hard retain-window seat + # for this run's release. Dropping $TAG from the list *before* the slice + # would permanently keep retain+1 releases; skipping it only in the + # delete loop would under-prune when the sort is still wrong. Partition + # keeps relative order of every other tag. + jq_filter='map(select(.isDraft | not)) | sort_by(.publishedAt // "", .tagName) | reverse' + if [[ -n "${TAG:-}" ]]; then + jq_filter+=" | (map(select(.tagName == \"${TAG//\"/\\\"}\")) + map(select(.tagName != \"${TAG//\"/\\\"}\")))" + fi + jq_filter+=" | .[${DAILY_RETAIN_COUNT}:] | .[].tagName" + stale="$(gh release list --repo "$DAILY_REPO" --limit 200 --json tagName,publishedAt,isDraft \ + --jq "$jq_filter")" + if [[ -z "$stale" ]]; then + echo "Nothing to prune; at or under $DAILY_RETAIN_COUNT retained builds." + exit 0 + fi + while read -r tag; do + [[ -n "$tag" ]] || continue + # Belt-and-suspenders: partition above should already exclude $TAG. + if [[ -n "${TAG:-}" && "$tag" == "$TAG" ]]; then + echo "::warning::Prune list still included just-published $tag after protect; skipping delete." + continue + fi + echo "Pruning $tag" + gh release delete "$tag" --repo "$DAILY_REPO" --yes --cleanup-tag || \ + echo "::warning::Could not prune $tag" + done <<<"$stale" diff --git a/config/electron-builder.config.cjs b/config/electron-builder.config.cjs index e581cd7a4..9933df815 100644 --- a/config/electron-builder.config.cjs +++ b/config/electron-builder.config.cjs @@ -20,21 +20,32 @@ const { verifySkillsCliRuntime } = require('./scripts/verify-skills-cli-runtime. // Developer ID signature, and notarization ticket — or Squirrel.Mac refuses to // swap them over an installed Orca and macOS treats each build as a new app. const isMacHourly = process.env.ORCA_MAC_HOURLY === '1' +const isMacDaily = process.env.ORCA_MAC_DAILY === '1' const isMacAdhoc = process.env.ORCA_MAC_ADHOC === '1' -const isMacRelease = process.env.ORCA_MAC_RELEASE === '1' || isMacHourly || isMacAdhoc +const isMacRelease = + process.env.ORCA_MAC_RELEASE === '1' || isMacHourly || isMacDaily || isMacAdhoc const isLinuxArm64Release = process.env.ORCA_LINUX_ARM64_RELEASE === '1' const localBuildVersion = isMacRelease ? undefined : process.env.ORCA_LOCAL_BUILD_VERSION const devChannelBuildVersion = isMacHourly ? process.env.ORCA_HOURLY_BUILD_VERSION - : isMacAdhoc - ? process.env.ORCA_ADHOC_BUILD_VERSION - : undefined + : isMacDaily + ? process.env.ORCA_DAILY_BUILD_VERSION + : isMacAdhoc + ? process.env.ORCA_ADHOC_BUILD_VERSION + : undefined // Why each dev channel gets its own repo rather than tagging into the main one: // the releases atom feed exposes only the 10 newest entries, so 24 hourly tags a // day would evict every stable/RC entry and strand users on a feed with nothing -// to install. Keeping adhoc separate from hourly too means a branch build cannot -// be picked up by someone who only meant to ride main. -const devChannelRepo = isMacHourly ? 'orca-hourly' : isMacAdhoc ? 'orca-adhoc' : null +// to install. Keeping adhoc/daily separate from hourly too means a branch build +// or a once-a-day cut cannot be picked up by someone who only meant to ride +// main's hourlies. +const devChannelRepo = isMacHourly + ? 'orca-hourly' + : isMacDaily + ? 'orca-daily' + : isMacAdhoc + ? 'orca-adhoc' + : null const appId = 'com.stablyai.orca' const featureWallResources = { from: 'resources/onboarding/feature-wall', diff --git a/config/scripts/daily-build-version.mjs b/config/scripts/daily-build-version.mjs new file mode 100755 index 000000000..1d27ef5fb --- /dev/null +++ b/config/scripts/daily-build-version.mjs @@ -0,0 +1,109 @@ +import { execFileSync } from 'node:child_process' +import { readFileSync } from 'node:fs' +import { resolve } from 'node:path' +import { formatReleaseTitleTimestamp } from './release-title-timestamp.mjs' +import { + readPublishedVersionsFromEnv, + resolveDevChannelBaseVersion +} from './dev-channel-base-version.mjs' + +/** `1.4.160-daily.202607281300` — UTC to the minute, so tags sort chronologically + * by semver and every build is uniquely versioned. */ +export function createDailyBuildVersion(baseVersion, date) { + const match = /^(\d+\.\d+\.\d+)(?:-[0-9A-Za-z.-]+)?$/.exec(baseVersion) + if (!match) { + throw new Error(`Package version is not valid semver: ${baseVersion}`) + } + if (!(date instanceof Date) || Number.isNaN(date.getTime())) { + throw new Error('Daily build timestamp is invalid.') + } + const pad = (value, width = 2) => String(value).padStart(width, '0') + const stamp = [ + pad(date.getUTCFullYear(), 4), + pad(date.getUTCMonth() + 1), + pad(date.getUTCDate()), + pad(date.getUTCHours()), + pad(date.getUTCMinutes()) + ].join('') + // Why: drop any -rc.N tail. Keeping it makes every daily semver-NEWER than the + // RC it was cut from (1.4.160-rc.3-daily.X > 1.4.160-rc.3), which would let an + // ordinary RC-channel check offer untested daily builds to RC users. Stripping + // to the base parks dailies below both rc.N and stable ('daily' < 'rc' + // alphabetically), reachable only by an explicit pinned jump. + return `${match[1]}-daily.${stamp}` +} + +/** + * The next build number for `baseVersion`, counting from the titles of existing + * daily releases. + * + * Why the series restarts at 01 on every base version: the number answers "which + * build of 1.4.163 is this", so a counter shared across versions makes it + * meaningless — 1.4.164 would open at 38 for no reason a reader can see. + * + * Why the maximum rather than a count: the prune step trims to + * DAILY_RETAIN_COUNT, so a count would roll backwards and reissue a number + * already in use. Titles that predate this naming simply do not match, which is + * how the first build of a version lands on 01. + */ +export function nextDailyBuildNumber(baseVersion, releaseNames = []) { + const prefix = `${baseVersion} • ` + const highest = releaseNames.reduce((max, entry) => { + const name = String(entry ?? '') + if (!name.startsWith(prefix)) { + return max + } + const match = /^(\d+) • /.exec(name.slice(prefix.length)) + return match ? Math.max(max, Number(match[1])) : max + }, 0) + return highest + 1 +} + +/** + * `1.4.163 • 01 • Aug 9, 6:15AM • e698241` — the human-facing release title, + * shown verbatim in both the GitHub releases list and the in-app build picker. + */ +export function formatDailyReleaseName(version, buildNumber, commit, date) { + if (!Number.isInteger(buildNumber) || buildNumber < 1) { + throw new Error(`Daily build number must be a positive integer: ${buildNumber}`) + } + return [ + version.split('-')[0], + String(buildNumber).padStart(2, '0'), + formatReleaseTitleTimestamp(date), + commit.slice(0, 7) + ].join(' • ') +} + +// Why the number is derived here rather than passed in: it counts builds of the +// base version, and the base is only known once the published tags have been +// resolved just above. Computing it outside meant numbering against whatever +// version the caller guessed. +export function getDailyBuildIdentity(now = new Date(), { publishedVersions, releaseNames } = {}) { + const packageJson = JSON.parse(readFileSync(resolve('package.json'), 'utf8')) + const commit = execFileSync('git', ['rev-parse', '--short=12', 'HEAD'], { + encoding: 'utf8' + }).trim() + const base = resolveDevChannelBaseVersion(packageJson.version, publishedVersions ?? []) + const version = createDailyBuildVersion(base, now) + const buildNumber = nextDailyBuildNumber(base, releaseNames ?? []) + return { + commit, + version, + buildNumber, + name: formatDailyReleaseName(version, buildNumber, commit, now) + } +} + +if (process.argv[1] && resolve(process.argv[1]) === resolve(import.meta.filename)) { + const identity = getDailyBuildIdentity(new Date(), { + publishedVersions: readPublishedVersionsFromEnv(), + // Titles are newline separated and contain spaces, so this cannot reuse the + // whitespace split the version list gets. + releaseNames: (process.env.ORCA_DAILY_RELEASE_NAMES ?? '').split('\n').filter(Boolean) + }) + // Consumed by the workflow via $GITHUB_OUTPUT. + process.stdout.write( + `version=${identity.version}\ncommit=${identity.commit}\nbuild_number=${identity.buildNumber}\nname=${identity.name}\n` + ) +} diff --git a/config/scripts/daily-build-version.test.mjs b/config/scripts/daily-build-version.test.mjs new file mode 100644 index 000000000..7f0e71b92 --- /dev/null +++ b/config/scripts/daily-build-version.test.mjs @@ -0,0 +1,111 @@ +import { describe, expect, it } from 'vitest' +import { + createDailyBuildVersion, + formatDailyReleaseName, + nextDailyBuildNumber +} from './daily-build-version.mjs' +import { compareAppVersions } from '../../src/shared/app-version' + +describe('createDailyBuildVersion', () => { + it('stamps the version with a zero-padded UTC timestamp', () => { + expect(createDailyBuildVersion('1.4.160', new Date('2026-07-28T13:00:00Z'))).toBe( + '1.4.160-daily.202607281300' + ) + }) + + // Why: main's package.json carries the in-flight RC tail. Keeping it would make + // every daily semver-NEWER than the RC it was cut from (1.4.160-rc.3-daily.X > + // 1.4.160-rc.3), so an ordinary RC-channel check would offer untested daily + // builds to RC users. Dropping it parks dailies below both rc.N and stable, + // reachable only by an explicit pinned jump. + it('drops an in-flight rc tail so dailies never outrank the rc series', () => { + const version = createDailyBuildVersion('1.4.160-rc.3', new Date('2026-07-28T13:00:00Z')) + expect(version).toBe('1.4.160-daily.202607281300') + expect(compareAppVersions(version, '1.4.160-rc.3')).toBeLessThan(0) + expect(compareAppVersions('1.4.160-rc.3-daily.202607281300', '1.4.160-rc.3')).toBeGreaterThan(0) + }) + + // Why: 'daily' sorts before 'hourly' alphabetically, so a daily of the same + // base never outranks an hourly — both stay below rc/stable and only the + // channel picker offers them. + it('sorts below the hourly build of the same base version', () => { + expect( + compareAppVersions('1.4.160-daily.202607281300', '1.4.160-hourly.202607281400') + ).toBeLessThan(0) + }) + + it('rejects invalid input', () => { + expect(() => createDailyBuildVersion('nope', new Date())).toThrow(/valid semver/) + expect(() => createDailyBuildVersion('1.4.160', new Date('nope'))).toThrow(/invalid/) + }) +}) + +describe('formatDailyReleaseName', () => { + const name = (iso, buildNumber = 1, commit = 'e698241abcde') => + formatDailyReleaseName('1.4.163-daily.x', buildNumber, commit, new Date(iso)) + + it('renders version, number, Pacific timestamp, and short sha', () => { + // 13:15 UTC is 6:15AM PDT in July (the daily cut time). + expect(name('2026-07-28T13:15:00Z')).toBe('1.4.163 • 01 • Jul 28, 6:15AM • e698241') + }) + + // Why both sides of DST: the tag's stamp is UTC and the title is Pacific, so + // the offset between them is not a constant. A test pinned to one season would + // pass all summer and start failing in November. + it('follows the Pacific offset across DST', () => { + expect(name('2026-01-15T14:15:00Z')).toBe('1.4.163 • 01 • Jan 15, 6:15AM • e698241') + expect(name('2026-07-28T13:15:00Z')).toBe('1.4.163 • 01 • Jul 28, 6:15AM • e698241') + }) + + it('pads to two digits and grows past them', () => { + expect(name('2026-07-28T13:15:00Z', 9)).toContain(' • 09 • ') + expect(name('2026-07-28T13:15:00Z', 42)).toContain(' • 42 • ') + }) + + it('rejects a build number that is not a positive integer', () => { + expect(() => name('2026-07-28T13:15:00Z', 0)).toThrow(/positive integer/) + expect(() => name('2026-07-28T13:15:00Z', -1)).toThrow(/positive integer/) + expect(() => name('2026-07-28T13:15:00Z', 1.5)).toThrow(/positive integer/) + }) + + it('rejects an invalid timestamp', () => { + expect(() => formatDailyReleaseName('1.4.163', 1, 'abcdefg', new Date('nope'))).toThrow( + /invalid/ + ) + }) +}) + +describe('nextDailyBuildNumber', () => { + const titles = [ + '1.4.163 • 01 • Jul 28, 6:00AM • e698241', + '1.4.163 • 02 • Jul 29, 6:00AM • aaaaaaa', + '1.4.163 • 09 • Aug 01, 6:00AM • bbbbbbb' + ] + + it('continues the series for the version being built', () => { + expect(nextDailyBuildNumber('1.4.163', titles)).toBe(10) + }) + + it('restarts at 1 when the base version moves', () => { + expect(nextDailyBuildNumber('1.4.164', titles)).toBe(1) + expect( + nextDailyBuildNumber('1.4.164', [...titles, '1.4.164 • 01 • Aug 02, 6:00AM • ccccccc']) + ).toBe(2) + }) + + // Why max and not count: pruning trims to DAILY_RETAIN_COUNT, so counting + // would roll backwards and reissue a number already used. + it('takes the highest number, not the count', () => { + expect(nextDailyBuildNumber('1.4.163', ['1.4.163 • 09 • Jul 31, 6:00AM • e698241'])).toBe(10) + }) + + it('starts at 1 with no history at all', () => { + expect(nextDailyBuildNumber('1.4.163')).toBe(1) + expect(nextDailyBuildNumber('1.4.163', [])).toBe(1) + }) + + it('ignores titles that are not this version', () => { + expect(nextDailyBuildNumber('1.4.16', ['1.4.163 • 09 • Aug 01, 6:00AM • bbbbbbb'])).toBe(1) + expect(nextDailyBuildNumber('1.4.163', ['v1.4.163-daily.202607311300', null, ''])).toBe(1) + }) +}) diff --git a/config/scripts/electron-builder-config.test.mjs b/config/scripts/electron-builder-config.test.mjs index 3227d957b..2ecda0e80 100644 --- a/config/scripts/electron-builder-config.test.mjs +++ b/config/scripts/electron-builder-config.test.mjs @@ -19,42 +19,6 @@ const { verifyPackagedMainRuntimeDeps } = require('../packaged-runtime-node-modules.cjs') -const MUTABLE_BUILD_ENV = [ - 'ORCA_MAC_HOURLY', - 'ORCA_MAC_ADHOC', - 'ORCA_MAC_RELEASE', - 'ORCA_HOURLY_BUILD_VERSION', - 'ORCA_ADHOC_BUILD_VERSION', - 'ORCA_LOCAL_BUILD_VERSION' -] - -/** Re-requires the config under a temporary env, then restores env and module cache. */ -function withEnv(env, assert) { - const configPath = require.resolve('../electron-builder.config.cjs') - const original = Object.fromEntries(MUTABLE_BUILD_ENV.map((key) => [key, process.env[key]])) - try { - for (const key of MUTABLE_BUILD_ENV) { - delete process.env[key] - } - Object.assign(process.env, env) - delete require.cache[configPath] - assert(require('../electron-builder.config.cjs')) - } finally { - for (const [key, value] of Object.entries(original)) { - if (value === undefined) { - delete process.env[key] - } else { - process.env[key] = value - } - } - delete require.cache[configPath] - require('../electron-builder.config.cjs') - } -} - -const withHourlyEnv = (assert) => withEnv({ ORCA_MAC_HOURLY: '1' }, assert) -const withAdhocEnv = (assert) => withEnv({ ORCA_MAC_ADHOC: '1' }, assert) - describe('electron-builder config', () => { it('keeps the packaged app identity aligned with local-build validation', () => { expect(electronBuilderConfig.appId).toBe( @@ -303,89 +267,6 @@ describe('electron-builder config', () => { } }) - // Why: Squirrel.Mac swaps the .app in place only when the replacement carries the - // same bundle id and a valid Developer ID signature. A hourly built on the local - // (com.stablyai.orca.local, ad-hoc) identity would be un-installable over a real - // Orca — the whole point of the channel. - it('builds hourly artifacts with the release signing identity', () => { - withHourlyEnv((config) => { - expect(config.mac.appId).toBeUndefined() - expect(config.appId).toBe('com.stablyai.orca') - expect(config.mac.hardenedRuntime).toBe(true) - expect(config.forceCodeSigning).toBe(true) - }) - }) - - // Why hourly must notarize despite the round trip: TCC anchors a notarized - // Developer ID app's grants on identifier + team, not on its cdhash, so they - // survive an update. An unnotarized hourly reads as a new client every build - // and loses file access under Documents/Desktop/Downloads with no re-prompt. - it('notarizes hourly builds like releases, and neither locally', () => { - withHourlyEnv((config) => { - expect(config.mac.notarize).toBe(true) - }) - withEnv({ ORCA_MAC_RELEASE: '1' }, (config) => { - expect(config.mac.notarize).toBe(true) - }) - expect(electronBuilderConfig.mac.notarize).toBe(false) - }) - - // Why: the main repo's releases atom feed exposes only its 10 newest entries. - // Publishing 24 hourly tags a day there would evict every stable/RC entry and - // break update checks for every real user. - it('publishes hourly builds to the separate hourly repo', () => { - withHourlyEnv((config) => { - expect(config.publish).toMatchObject({ repo: 'orca-hourly', releaseType: 'prerelease' }) - }) - expect(electronBuilderConfig.publish).toMatchObject({ - repo: 'orca', - releaseType: 'release' - }) - }) - - it('stamps hourly packages with the hourly version', () => { - withEnv( - { ORCA_MAC_HOURLY: '1', ORCA_HOURLY_BUILD_VERSION: '1.4.160-hourly.202607281400' }, - (config) => { - expect(config.extraMetadata).toEqual({ version: '1.4.160-hourly.202607281400' }) - } - ) - }) - - // Why adhoc carries the identical mac identity to hourly: it installs over a - // real Orca through the same updater path, so the same signing and the same TCC - // argument apply. Only the destination repo differs. - it('builds adhoc artifacts with the release identity and its own repo', () => { - withAdhocEnv((config) => { - expect(config.appId).toBe('com.stablyai.orca') - expect(config.mac.hardenedRuntime).toBe(true) - expect(config.mac.notarize).toBe(true) - expect(config.forceCodeSigning).toBe(true) - expect(config.publish).toMatchObject({ repo: 'orca-adhoc', releaseType: 'prerelease' }) - }) - }) - - it('stamps adhoc packages with the adhoc version', () => { - withEnv( - { ORCA_MAC_ADHOC: '1', ORCA_ADHOC_BUILD_VERSION: '1.4.160-adhoc.20260728140533' }, - (config) => { - expect(config.extraMetadata).toEqual({ version: '1.4.160-adhoc.20260728140533' }) - } - ) - }) - - // Why: the two dev channels share every packaging decision except where they - // publish, so a future edit that collapses them must not also collapse the - // repos — a branch build landing in orca-hourly would be offered to everyone - // riding main. - it('keeps the two dev channels on separate repos', () => { - withHourlyEnv((hourly) => { - withAdhocEnv((adhoc) => { - expect(hourly.publish.repo).not.toBe(adhoc.publish.repo) - }) - }) - }) - it('uses Orca native rebuild hook instead of electron-builder default rebuild', () => { expect(electronBuilderConfig.beforeBuild).toBe(electronBuilderNativeRebuild) expect(electronBuilderConfig.npmRebuild).toBe(true) diff --git a/config/scripts/electron-builder-mac-channel-config.test.mjs b/config/scripts/electron-builder-mac-channel-config.test.mjs new file mode 100644 index 000000000..dbd5a1170 --- /dev/null +++ b/config/scripts/electron-builder-mac-channel-config.test.mjs @@ -0,0 +1,152 @@ +import { createRequire } from 'node:module' +import { describe, expect, it } from 'vitest' + +const require = createRequire(import.meta.url) +const electronBuilderConfig = require('../electron-builder.config.cjs') + +const MUTABLE_BUILD_ENV = [ + 'ORCA_MAC_HOURLY', + 'ORCA_MAC_DAILY', + 'ORCA_MAC_ADHOC', + 'ORCA_MAC_RELEASE', + 'ORCA_HOURLY_BUILD_VERSION', + 'ORCA_DAILY_BUILD_VERSION', + 'ORCA_ADHOC_BUILD_VERSION', + 'ORCA_LOCAL_BUILD_VERSION' +] + +/** Re-requires the config under a temporary env, then restores env and module cache. */ +function withEnv(env, assert) { + const configPath = require.resolve('../electron-builder.config.cjs') + const original = Object.fromEntries(MUTABLE_BUILD_ENV.map((key) => [key, process.env[key]])) + try { + for (const key of MUTABLE_BUILD_ENV) { + delete process.env[key] + } + Object.assign(process.env, env) + delete require.cache[configPath] + assert(require('../electron-builder.config.cjs')) + } finally { + for (const [key, value] of Object.entries(original)) { + if (value === undefined) { + delete process.env[key] + } else { + process.env[key] = value + } + } + delete require.cache[configPath] + require('../electron-builder.config.cjs') + } +} + +const withHourlyEnv = (assert) => withEnv({ ORCA_MAC_HOURLY: '1' }, assert) +const withDailyEnv = (assert) => withEnv({ ORCA_MAC_DAILY: '1' }, assert) +const withAdhocEnv = (assert) => withEnv({ ORCA_MAC_ADHOC: '1' }, assert) + +describe('electron-builder mac channel config', () => { + // Why: Squirrel.Mac swaps the .app in place only when the replacement carries the + // same bundle id and a valid Developer ID signature. A hourly built on the local + // (com.stablyai.orca.local, ad-hoc) identity would be un-installable over a real + // Orca — the whole point of the channel. + it('builds hourly artifacts with the release signing identity', () => { + withHourlyEnv((config) => { + expect(config.mac.appId).toBeUndefined() + expect(config.appId).toBe('com.stablyai.orca') + expect(config.mac.hardenedRuntime).toBe(true) + expect(config.forceCodeSigning).toBe(true) + }) + }) + + // Why hourly must notarize despite the round trip: TCC anchors a notarized + // Developer ID app's grants on identifier + team, not on its cdhash, so they + // survive an update. An unnotarized hourly reads as a new client every build + // and loses file access under Documents/Desktop/Downloads with no re-prompt. + it('notarizes hourly builds like releases, and neither locally', () => { + withHourlyEnv((config) => { + expect(config.mac.notarize).toBe(true) + }) + withEnv({ ORCA_MAC_RELEASE: '1' }, (config) => { + expect(config.mac.notarize).toBe(true) + }) + expect(electronBuilderConfig.mac.notarize).toBe(false) + }) + + // Why: the main repo's releases atom feed exposes only its 10 newest entries. + // Publishing 24 hourly tags a day there would evict every stable/RC entry and + // break update checks for every real user. + it('publishes hourly builds to the separate hourly repo', () => { + withHourlyEnv((config) => { + expect(config.publish).toMatchObject({ repo: 'orca-hourly', releaseType: 'prerelease' }) + }) + expect(electronBuilderConfig.publish).toMatchObject({ + repo: 'orca', + releaseType: 'release' + }) + }) + + it('stamps hourly packages with the hourly version', () => { + withEnv( + { ORCA_MAC_HOURLY: '1', ORCA_HOURLY_BUILD_VERSION: '1.4.160-hourly.202607281400' }, + (config) => { + expect(config.extraMetadata).toEqual({ version: '1.4.160-hourly.202607281400' }) + } + ) + }) + + // Why adhoc carries the identical mac identity to hourly: it installs over a + // real Orca through the same updater path, so the same signing and the same TCC + // argument apply. Only the destination repo differs. + it('builds adhoc artifacts with the release identity and its own repo', () => { + withAdhocEnv((config) => { + expect(config.appId).toBe('com.stablyai.orca') + expect(config.mac.hardenedRuntime).toBe(true) + expect(config.mac.notarize).toBe(true) + expect(config.forceCodeSigning).toBe(true) + expect(config.publish).toMatchObject({ repo: 'orca-adhoc', releaseType: 'prerelease' }) + }) + }) + + it('stamps adhoc packages with the adhoc version', () => { + withEnv( + { ORCA_MAC_ADHOC: '1', ORCA_ADHOC_BUILD_VERSION: '1.4.160-adhoc.20260728140533' }, + (config) => { + expect(config.extraMetadata).toEqual({ version: '1.4.160-adhoc.20260728140533' }) + } + ) + }) + + it('builds daily artifacts with the release identity and its own repo', () => { + withDailyEnv((config) => { + expect(config.appId).toBe('com.stablyai.orca') + expect(config.mac.hardenedRuntime).toBe(true) + expect(config.mac.notarize).toBe(true) + expect(config.forceCodeSigning).toBe(true) + expect(config.publish).toMatchObject({ repo: 'orca-daily', releaseType: 'prerelease' }) + }) + }) + + it('stamps daily packages with the daily version', () => { + withEnv( + { ORCA_MAC_DAILY: '1', ORCA_DAILY_BUILD_VERSION: '1.4.160-daily.202607281300' }, + (config) => { + expect(config.extraMetadata).toEqual({ version: '1.4.160-daily.202607281300' }) + } + ) + }) + + // Why: the dev channels share every packaging decision except where they + // publish, so a future edit that collapses them must not also collapse the + // repos — a branch or daily build landing in orca-hourly would be offered to + // everyone riding main's hourlies. + it('keeps the dev channels on separate repos', () => { + withHourlyEnv((hourly) => { + withDailyEnv((daily) => { + withAdhocEnv((adhoc) => { + expect(new Set([hourly.publish.repo, daily.publish.repo, adhoc.publish.repo]).size).toBe( + 3 + ) + }) + }) + }) + }) +}) diff --git a/config/scripts/setup-adhoc-release-repo.sh b/config/scripts/setup-adhoc-release-repo.sh index 3e232c5fc..1f3debf12 100755 --- a/config/scripts/setup-adhoc-release-repo.sh +++ b/config/scripts/setup-adhoc-release-repo.sh @@ -86,7 +86,7 @@ Could not do it from here${INSTALL_ID:+ (needs an Organization Owner)}. Do it in 1. Open: https://github.com/organizations/$ORG/settings/installations 2. Configure -> $APP_SLUG 3. Repository access -> Only select repositories -> add $ADHOC_REPO - (keep orca-hourly selected; both dev channels use this one App) + (keep orca-hourly/orca-daily selected; all dev channels use this one App) 4. Save. EOF fi diff --git a/config/scripts/setup-daily-release-repo.sh b/config/scripts/setup-daily-release-repo.sh new file mode 100755 index 000000000..e9437c0c6 --- /dev/null +++ b/config/scripts/setup-daily-release-repo.sh @@ -0,0 +1,98 @@ +#!/usr/bin/env bash +# +# Creates stablyai/orca-daily and grants the existing release App write access to +# it, so daily-mac-build.yml can publish there. +# +# Why a separate repo rather than reusing orca-hourly: the daily channel is a +# once-a-day cut that people ride deliberately. Sharing hourly's list would mix a +# sparse daily series into the 72-entry hourly retention window and make both +# pickers harder to read. +# +# Why no secrets are set here: the daily workflow reuses the same GitHub App as +# hourly/adhoc — one App id, one private key, one thing to rotate. This script +# only has to widen that App's installation to cover the new repo. +# +# Run once, after config/scripts/setup-hourly-release-token.sh: +# bash config/scripts/setup-daily-release-repo.sh +# +set -euo pipefail + +ORG="stablyai" +DAILY_REPO="$ORG/orca-daily" +MAIN_REPO="$ORG/orca" +APP_SLUG="orca-hourly-release" + +fail() { + echo "error: $*" >&2 + exit 1 +} + +command -v gh >/dev/null 2>&1 || fail "gh CLI not found. See https://cli.github.com" +gh auth status >/dev/null 2>&1 || fail "Not logged in. Run: gh auth login" + +if gh api "repos/$DAILY_REPO" --jq '.full_name' >/dev/null 2>&1; then + echo "$DAILY_REPO already exists." +else + echo "Creating $DAILY_REPO..." + # Why public: the in-app updater fetches release assets unauthenticated, exactly + # as it does for orca-hourly. A private repo would 404 for every client. + # + # Why the features are off: this repo holds releases and nothing else. Leaving + # issues open invites bug reports against an unvetted daily in a repo nobody + # watches, where they are simply lost. + # + # Why --add-readme in a repo with no source: publishing a release creates a tag, + # and a tag needs a commit. Empty repo = "Repository is empty" 25 minutes in. + gh repo create "$DAILY_REPO" \ + --public \ + --description "Daily macOS dev builds of Orca, cut from main each morning. Not a source repo." \ + --add-readme \ + --disable-issues \ + --disable-wiki || + fail "Could not create $DAILY_REPO." +fi + +# Also checked outside the create branch: a repo made before --add-readme is here. +if ! gh api "repos/$DAILY_REPO/commits" --jq 'length' >/dev/null 2>&1; then + fail "$DAILY_REPO has no commits — releases cannot be tagged. Add any file to it first." +fi + +echo +echo "Granting $APP_SLUG access to $DAILY_REPO..." + +# Why attempt the API before printing instructions: an org owner can do this in +# one call. Everyone else gets a 403 and the manual path below — GitHub does not +# let a mere admin widen an App's repository selection. +INSTALL_ID="$(gh api "orgs/$ORG/installations" --paginate \ + --jq ".installations[] | select(.app_slug == \"$APP_SLUG\") | .id" 2>/dev/null || true)" +REPO_ID="$(gh api "repos/$DAILY_REPO" --jq '.id' 2>/dev/null || true)" + +GRANTED=false +if [[ -n "$INSTALL_ID" && -n "$REPO_ID" ]]; then + if gh api -X PUT "user/installations/$INSTALL_ID/repositories/$REPO_ID" >/dev/null 2>&1; then + GRANTED=true + echo "Done — $APP_SLUG can now write to $DAILY_REPO." + fi +fi + +if [[ "$GRANTED" != "true" ]]; then + # Why no automated check afterwards: the endpoints that report an App's + # repository access (repos/*/installation, user/installations/*/repositories) + # both reject an ordinary `gh auth login` token, so any "verified" this script + # printed would be guesswork. The smoke test below is the real check. + cat < $APP_SLUG + 3. Repository access -> Only select repositories -> add $DAILY_REPO + (keep orca-hourly and orca-adhoc selected; all dev channels use this one App) + 4. Save. +EOF +fi + +echo +echo "Smoke-test the pipeline (after this merges):" +echo " gh workflow run daily-mac-build.yml --repo $MAIN_REPO -f force=true" +echo " gh run watch --repo $MAIN_REPO" diff --git a/config/scripts/setup-hourly-release-token.sh b/config/scripts/setup-hourly-release-token.sh index 3d846bc01..77f6b4834 100755 --- a/config/scripts/setup-hourly-release-token.sh +++ b/config/scripts/setup-hourly-release-token.sh @@ -8,9 +8,10 @@ # — no yearly rotation — and it belongs to the org rather than to the person who # created it, so it survives that person leaving. # -# The same App also serves adhoc-mac-build.yml, which reads these same two -# secrets: one credential, one rotation, both dev channels. Widening it to cover -# stablyai/orca-adhoc is config/scripts/setup-adhoc-release-repo.sh's job. +# The same App also serves adhoc-mac-build.yml and daily-mac-build.yml, which +# read these same two secrets: one credential, one rotation, all dev channels. +# Widening it to cover stablyai/orca-adhoc / orca-daily is +# setup-adhoc-release-repo.sh / setup-daily-release-repo.sh's job. # # The key is read from a file and piped straight into `gh secret set`. It is never # echoed, never passed as a command-line argument (argv is world-readable via diff --git a/src/main/updater-release-builds.test.ts b/src/main/updater-release-builds.test.ts index b9344954a..5e7b55e9f 100644 --- a/src/main/updater-release-builds.test.ts +++ b/src/main/updater-release-builds.test.ts @@ -45,6 +45,25 @@ describe('listReleaseBuilds', () => { ]) }) + it('lists daily builds from the dedicated repo, newest first', async () => { + fetchMock.mockResolvedValue( + jsonResponse([ + release('v1.4.160-daily.202607271300'), + release('v1.4.160-daily.202607291300'), + release('v1.4.160-daily.202607281300') + ]) + ) + + const builds = await listReleaseBuilds('daily') + + expect(fetchMock.mock.calls[0][0]).toContain('stablyai/orca-daily') + expect(builds.map((build) => build.version)).toEqual([ + '1.4.160-daily.202607291300', + '1.4.160-daily.202607281300', + '1.4.160-daily.202607271300' + ]) + }) + // Why: the main repo serves stable and rc from one endpoint, so an unfiltered // list would offer RC tags under the Stable channel. it('separates stable from rc in the shared main repo', async () => { @@ -124,6 +143,15 @@ describe('resolveTargetBuild', () => { }) }) + it('pins a daily tag at the daily repo download path', () => { + expect(resolveTargetBuild('daily', 'v1.4.160-daily.202607281300')).toEqual({ + tag: 'v1.4.160-daily.202607281300', + version: '1.4.160-daily.202607281300', + feedUrl: + 'https://github.com/stablyai/orca-daily/releases/download/v1.4.160-daily.202607281300' + }) + }) + it('pins a stable tag at the main repo download path', () => { expect(resolveTargetBuild('stable', 'v1.4.159').feedUrl).toBe( 'https://github.com/stablyai/orca/releases/download/v1.4.159' diff --git a/src/main/updater.test.ts b/src/main/updater.test.ts index dce8b39a7..1f5cdc069 100644 --- a/src/main/updater.test.ts +++ b/src/main/updater.test.ts @@ -301,6 +301,7 @@ describe('updater', () => { it.each([ ['hourly', 'v1.4.160-hourly.202607281400', 'Hourly builds are produced only for macOS.'], + ['daily', 'v1.4.160-daily.202607281300', 'Daily builds are produced only for macOS.'], ['adhoc', 'v1.4.160-adhoc.20260728140533', 'Adhoc builds are produced only for macOS.'] ] as const)( 'uses the display label in the mac-only %s pinned-build error', diff --git a/src/renderer/src/components/settings/ReleaseChannelSection.tsx b/src/renderer/src/components/settings/ReleaseChannelSection.tsx index b89d8aa50..bfe2321bd 100644 --- a/src/renderer/src/components/settings/ReleaseChannelSection.tsx +++ b/src/renderer/src/components/settings/ReleaseChannelSection.tsx @@ -16,6 +16,7 @@ import { hasDedicatedReleaseRepo, isChannelSupportedOnPlatform, parseDevBuildStamp, + type DedicatedRepoChannel, type ReleaseBuild, type ReleaseChannel } from '../../../../shared/release-channel' @@ -24,9 +25,30 @@ const CHANNEL_DESCRIPTIONS: Record = { stable: 'Shipped releases. What everyone else is running.', rc: 'Release candidates cut ahead of each stable.', hourly: 'macOS only. Unvetted builds from main, built every hour. No tests.', + daily: + 'macOS only. Unvetted builds from main, cut once a day at 14:15 UTC (early morning Pacific). No tests.', adhoc: 'macOS only. One-off builds cut from a branch to try a feature before it lands.' } +const DEDICATED_CHANNEL_WARNINGS: Record = + { + hourly: { + key: 'auto.components.settings.ReleaseChannelSection.hourlyWarning', + fallback: + 'Hourly builds are macOS-only and ship straight from main with no test gate. Keep a stable build handy.' + }, + daily: { + key: 'auto.components.settings.ReleaseChannelSection.dailyWarning', + fallback: + 'Daily builds are macOS-only and ship straight from main with no test gate. Keep a stable build handy.' + }, + adhoc: { + key: 'auto.components.settings.ReleaseChannelSection.adhocWarning', + fallback: + 'Adhoc builds are macOS-only and come from a branch that has not landed. Whoever cut one may abandon it — keep a stable build handy.' + } + } + function formatBuildLabel(build: ReleaseBuild): string { // Why the release's own title wins: the build workflows compose it (hourly // `1.4.163 • 01 • Jul 31, 1:54PM • e698241`, adhoc `1.4.163 • wasm-terminal • …`), @@ -226,15 +248,10 @@ export function ReleaseChannelSection(): React.JSX.Element {

- {activeChannel === 'hourly' - ? translate( - 'auto.components.settings.ReleaseChannelSection.hourlyWarning', - 'Hourly builds are macOS-only and ship straight from main with no test gate. Keep a stable build handy.' - ) - : translate( - 'auto.components.settings.ReleaseChannelSection.adhocWarning', - 'Adhoc builds are macOS-only and come from a branch that has not landed. Whoever cut one may abandon it — keep a stable build handy.' - )} + {translate( + DEDICATED_CHANNEL_WARNINGS[activeChannel].key, + DEDICATED_CHANNEL_WARNINGS[activeChannel].fallback + )}

) : null} diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 08fd9b389..3e0980342 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -10389,6 +10389,7 @@ "devOnly": "Dev only", "channelAriaLabel": "Update channel", "hourlyWarning": "Hourly builds are macOS-only and ship straight from main with no test gate. Keep a stable build handy.", + "dailyWarning": "Daily builds are macOS-only and ship straight from main with no test gate. Keep a stable build handy.", "adhocWarning": "Adhoc builds are macOS-only and come from a branch that has not landed. Whoever cut one may abandon it — keep a stable build handy.", "loadingBuilds": "Loading builds…", "noBuilds": "No builds found", diff --git a/src/shared/release-channel.test.ts b/src/shared/release-channel.test.ts index a12cc94c9..fc7172efb 100644 --- a/src/shared/release-channel.test.ts +++ b/src/shared/release-channel.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from 'vitest' import { formatAdhocVersion, + formatDailyVersion, formatHourlyVersion, getReleaseNotesUrlForVersion, getReleaseRepoForChannel, @@ -8,9 +9,11 @@ import { hasDedicatedReleaseRepo, isAdhocVersion, isChannelSupportedOnPlatform, + isDailyVersion, isHourlyVersion, isReleaseChannel, parseAdhocVersionStamp, + parseDailyVersionStamp, parseDevBuildStamp, parseHourlyVersionStamp, sortReleaseBuildsNewestFirst, @@ -24,6 +27,7 @@ describe('release channel', () => { expect(getVersionChannel('v1.4.160')).toBe('stable') expect(getVersionChannel('1.4.160-rc.3')).toBe('rc') expect(getVersionChannel('1.4.160-hourly.202607281400')).toBe('hourly') + expect(getVersionChannel('1.4.160-daily.202607281300')).toBe('daily') expect(getVersionChannel('1.4.160-adhoc.20260728140533')).toBe('adhoc') expect(getVersionChannel('not-a-version')).toBeNull() }) @@ -33,7 +37,8 @@ describe('release channel', () => { // entry and leave real users with nothing to update to. it('keeps dev builds out of the main release repo, and apart from each other', () => { expect(getReleaseRepoForChannel('hourly')).toBe('stablyai/orca-hourly') - // Why adhoc gets a third repo rather than sharing hourly's: an unlanded + expect(getReleaseRepoForChannel('daily')).toBe('stablyai/orca-daily') + // Why adhoc gets its own repo rather than sharing hourly's: an unlanded // branch build must never surface to someone who only meant to ride main. expect(getReleaseRepoForChannel('adhoc')).toBe('stablyai/orca-adhoc') expect(getReleaseRepoForChannel('stable')).toBe('stablyai/orca') @@ -42,6 +47,7 @@ describe('release channel', () => { it('marks exactly the dev channels as having their own repo', () => { expect(hasDedicatedReleaseRepo('hourly')).toBe(true) + expect(hasDedicatedReleaseRepo('daily')).toBe(true) expect(hasDedicatedReleaseRepo('adhoc')).toBe(true) expect(hasDedicatedReleaseRepo('stable')).toBe(false) expect(hasDedicatedReleaseRepo('rc')).toBe(false) @@ -53,6 +59,9 @@ describe('release channel', () => { expect(getReleaseNotesUrlForVersion('1.4.160-hourly.202607281400')).toBe( 'https://github.com/stablyai/orca-hourly/releases/tag/v1.4.160-hourly.202607281400' ) + expect(getReleaseNotesUrlForVersion('1.4.160-daily.202607281300')).toBe( + 'https://github.com/stablyai/orca-daily/releases/tag/v1.4.160-daily.202607281300' + ) expect(getReleaseNotesUrlForVersion('1.4.160')).toBe( 'https://github.com/stablyai/orca/releases/tag/v1.4.160' ) @@ -71,6 +80,12 @@ describe('release channel', () => { expect(parseHourlyVersionStamp(version)?.toISOString()).toBe('2026-07-28T14:05:00.000Z') }) + it('round-trips a daily version stamp as UTC', () => { + const version = formatDailyVersion('1.4.160', '202607281300') + expect(isDailyVersion(version)).toBe(true) + expect(parseDailyVersionStamp(version)?.toISOString()).toBe('2026-07-28T13:00:00.000Z') + }) + it('rejects malformed hourly identifiers', () => { expect(isHourlyVersion('1.4.160-hourly')).toBe(false) expect(isHourlyVersion('1.4.160-hourly.2026')).toBe(false) @@ -91,6 +106,8 @@ describe('release channel', () => { expect(parseHourlyVersionStamp('1.4.160-hourly.202802290000')?.toISOString()).toBe( '2028-02-29T00:00:00.000Z' ) + expect(parseDailyVersionStamp('1.4.160-daily.202602300000')).toBeNull() + expect(parseDailyVersionStamp('not-a-version-daily.202601010000')).toBeNull() }) // Why seconds and not hourly's minutes: adhoc builds are dispatched on demand, @@ -102,9 +119,12 @@ describe('release channel', () => { expect(parseAdhocVersionStamp(version)?.toISOString()).toBe('2026-07-28T14:05:33.000Z') }) - it('keeps the two dev stamp formats from matching each other', () => { + it('keeps the dev stamp formats from matching each other', () => { expect(isAdhocVersion('1.4.160-hourly.202607281400')).toBe(false) expect(isHourlyVersion('1.4.160-adhoc.20260728140533')).toBe(false) + expect(isDailyVersion('1.4.160-hourly.202607281400')).toBe(false) + expect(isHourlyVersion('1.4.160-daily.202607281300')).toBe(false) + expect(isDailyVersion('1.4.160-adhoc.20260728140533')).toBe(false) // A 12-digit adhoc tail is an hourly stamp wearing the wrong identifier, not // a second-resolution one; rejecting it keeps the parse unambiguous. expect(isAdhocVersion('1.4.160-adhoc.202607281405')).toBe(false) @@ -118,13 +138,16 @@ describe('release channel', () => { expect(parseAdhocVersionStamp('not-a-version-adhoc.20260101000000')).toBeNull() }) - // Why one entry point for both: the picker renders a row without knowing which + // Why one entry point for all: the picker renders a row without knowing which // dev channel produced it, so a channel added without a case here would fall // back to showing its raw opaque timestamp tail. - it('reads the build timestamp of either dev channel', () => { + it('reads the build timestamp of any dev channel', () => { expect(parseDevBuildStamp('1.4.160-hourly.202607281405')?.toISOString()).toBe( '2026-07-28T14:05:00.000Z' ) + expect(parseDevBuildStamp('1.4.160-daily.202607281300')?.toISOString()).toBe( + '2026-07-28T13:00:00.000Z' + ) expect(parseDevBuildStamp('1.4.160-adhoc.20260728140533')?.toISOString()).toBe( '2026-07-28T14:05:33.000Z' ) @@ -132,11 +155,11 @@ describe('release channel', () => { expect(parseDevBuildStamp('1.4.160')).toBeNull() }) - // Why: both dev workflows are macOS-only, so the channels have no artifact to + // Why: all dev workflows are macOS-only, so the channels have no artifact to // offer elsewhere. Both the picker and the main-process check read this, so a // regression here would silently re-expose an uninstallable channel. it('offers the dev channels only on macOS', () => { - for (const channel of ['hourly', 'adhoc'] as const) { + for (const channel of ['hourly', 'daily', 'adhoc'] as const) { expect(isChannelSupportedOnPlatform(channel, 'darwin')).toBe(true) expect(isChannelSupportedOnPlatform(channel, 'linux')).toBe(false) expect(isChannelSupportedOnPlatform(channel, 'win32')).toBe(false) @@ -152,6 +175,7 @@ describe('release channel', () => { it('accepts only known channels', () => { expect(isReleaseChannel('hourly')).toBe(true) + expect(isReleaseChannel('daily')).toBe(true) expect(isReleaseChannel('adhoc')).toBe(true) expect(isReleaseChannel('stable')).toBe(true) expect(isReleaseChannel('nightly')).toBe(false) @@ -188,6 +212,13 @@ describe('release channel', () => { expect(compareAppVersions('1.4.160-hourly.202607281400', '1.4.160')).toBeLessThan(0) }) + it('orders a daily below its own stable release and below hourly of the same base', () => { + expect(compareAppVersions('1.4.160-daily.202607281300', '1.4.160')).toBeLessThan(0) + expect( + compareAppVersions('1.4.160-daily.202607281300', '1.4.160-hourly.202607281400') + ).toBeLessThan(0) + }) + // Why adhoc sits at the very bottom: it is an unlanded branch, the least // trustworthy thing the updater can hand anyone. Every other channel of the // same base version must outrank it so no routine check ever selects one. @@ -196,6 +227,7 @@ describe('release channel', () => { expect(compareAppVersions(adhoc, '1.4.160')).toBeLessThan(0) expect(compareAppVersions(adhoc, '1.4.160-rc.1')).toBeLessThan(0) expect(compareAppVersions(adhoc, '1.4.160-hourly.202607280000')).toBeLessThan(0) + expect(compareAppVersions(adhoc, '1.4.160-daily.202607281300')).toBeLessThan(0) }) it('sorts consecutive adhoc builds newest first', () => { diff --git a/src/shared/release-channel.ts b/src/shared/release-channel.ts index 743ef6529..74b08f8ee 100644 --- a/src/shared/release-channel.ts +++ b/src/shared/release-channel.ts @@ -1,13 +1,20 @@ import { compareAppVersions, isValidAppVersion } from './app-version' -export type ReleaseChannel = 'stable' | 'rc' | 'hourly' | 'adhoc' +export type ReleaseChannel = 'stable' | 'rc' | 'hourly' | 'daily' | 'adhoc' -export const RELEASE_CHANNELS: readonly ReleaseChannel[] = ['stable', 'rc', 'hourly', 'adhoc'] +export const RELEASE_CHANNELS: readonly ReleaseChannel[] = [ + 'stable', + 'rc', + 'hourly', + 'daily', + 'adhoc' +] export const RELEASE_CHANNEL_LABELS: Readonly> = { stable: 'Stable', rc: 'RC', hourly: 'Hourly', + daily: 'Daily', adhoc: 'Adhoc' } @@ -15,14 +22,16 @@ export const RELEASE_CHANNEL_LABELS: Readonly> = * releases atom feed, which only exposes the 10 newest entries — 24 hourly * tags a day would evict every stable/RC entry and strand real users. */ export const HOURLY_RELEASE_REPO = 'stablyai/orca-hourly' +export const DAILY_RELEASE_REPO = 'stablyai/orca-daily' export const ADHOC_RELEASE_REPO = 'stablyai/orca-adhoc' export const MAIN_RELEASE_REPO = 'stablyai/orca' export const HOURLY_PRERELEASE_IDENTIFIER = 'hourly' +export const DAILY_PRERELEASE_IDENTIFIER = 'daily' export const ADHOC_PRERELEASE_IDENTIFIER = 'adhoc' /** The dev channels, each published to its own repo rather than the main one. */ -const DEDICATED_REPO_CHANNELS = ['hourly', 'adhoc'] as const +const DEDICATED_REPO_CHANNELS = ['hourly', 'daily', 'adhoc'] as const export type DedicatedRepoChannel = (typeof DEDICATED_REPO_CHANNELS)[number] @@ -30,6 +39,7 @@ const CHANNEL_RELEASE_REPOS: Record = { stable: MAIN_RELEASE_REPO, rc: MAIN_RELEASE_REPO, hourly: HOURLY_RELEASE_REPO, + daily: DAILY_RELEASE_REPO, adhoc: ADHOC_RELEASE_REPO } @@ -47,8 +57,8 @@ export function hasDedicatedReleaseRepo(channel: ReleaseChannel): channel is Ded * Shared so the picker, the main-process check, and any future surface cannot * drift on where a channel is available. * - * Why this rides on the dev-channel list: both dev channels are produced only by - * macOS workflows, so neither has an artifact to offer elsewhere. If one ever + * Why this rides on the dev-channel list: all dev channels are produced only by + * macOS workflows, so none has an artifact to offer elsewhere. If one ever * gains a Windows or Linux job, split the two concepts apart — they coincide * today, but "published to its own repo" and "built for macOS only" are not the * same claim. @@ -72,14 +82,19 @@ export function normalizeTagToVersion(tag: string): string { * uniquely versioned so electron-updater never reads one as "same version". */ const HOURLY_VERSION = /^\d+\.\d+\.\d+-hourly\.(\d{4})(\d{2})(\d{2})(\d{2})(\d{2})$/ +/** `1.4.160-daily.202607281415` — same minute stamp as hourly. Daily cuts once + * per day, so collisions are not a concern; the stamp still carries the hour so + * a forced re-cut the same calendar day remains unique. */ +const DAILY_VERSION = /^\d+\.\d+\.\d+-daily\.(\d{4})(\d{2})(\d{2})(\d{2})(\d{2})$/ + /** * `1.4.160-adhoc.20260728140533` — same idea, but stamped to the second. * - * Why seconds here and not for hourly: hourly runs under a concurrency group, so - * two of them can never be cut in the same minute. Adhoc builds are dispatched - * on demand by whoever wants one, so two people cutting from different branches - * at once is ordinary — and a minute-resolution stamp would collide on the tag - * and fail the second build eight minutes in. + * Why seconds here and not for hourly/daily: those run under a concurrency + * group, so two of them can never be cut in the same minute. Adhoc builds are + * dispatched on demand by whoever wants one, so two people cutting from + * different branches at once is ordinary — and a minute-resolution stamp would + * collide on the tag and fail the second build eight minutes in. */ const ADHOC_VERSION = /^\d+\.\d+\.\d+-adhoc\.(\d{4})(\d{2})(\d{2})(\d{2})(\d{2})(\d{2})$/ @@ -113,6 +128,10 @@ export function isHourlyVersion(version: string): boolean { return HOURLY_VERSION.test(normalizeTagToVersion(version)) } +export function isDailyVersion(version: string): boolean { + return DAILY_VERSION.test(normalizeTagToVersion(version)) +} + export function isAdhocVersion(version: string): boolean { return ADHOC_VERSION.test(normalizeTagToVersion(version)) } @@ -121,6 +140,10 @@ export function formatHourlyVersion(baseVersion: string, stamp: string): string return `${baseVersion}-${HOURLY_PRERELEASE_IDENTIFIER}.${stamp}` } +export function formatDailyVersion(baseVersion: string, stamp: string): string { + return `${baseVersion}-${DAILY_PRERELEASE_IDENTIFIER}.${stamp}` +} + export function formatAdhocVersion(baseVersion: string, stamp: string): string { return `${baseVersion}-${ADHOC_PRERELEASE_IDENTIFIER}.${stamp}` } @@ -130,15 +153,24 @@ export function parseHourlyVersionStamp(version: string): Date | null { return parseStampedVersion(version, HOURLY_VERSION) } +/** Returns the build's UTC timestamp, or null when the version isn't daily. */ +export function parseDailyVersionStamp(version: string): Date | null { + return parseStampedVersion(version, DAILY_VERSION) +} + /** Returns the build's UTC timestamp, or null when the version isn't adhoc. */ export function parseAdhocVersionStamp(version: string): Date | null { return parseStampedVersion(version, ADHOC_VERSION) } -/** The build's UTC timestamp for either dev channel, so a picker row can render - * a date without first working out which channel produced the version. */ +/** The build's UTC timestamp for any dev channel, so a picker row can render a + * date without first working out which channel produced the version. */ export function parseDevBuildStamp(version: string): Date | null { - return parseHourlyVersionStamp(version) ?? parseAdhocVersionStamp(version) + return ( + parseHourlyVersionStamp(version) ?? + parseDailyVersionStamp(version) ?? + parseAdhocVersionStamp(version) + ) } export function getVersionChannel(version: string): ReleaseChannel | null { @@ -149,6 +181,9 @@ export function getVersionChannel(version: string): ReleaseChannel | null { if (isHourlyVersion(normalized)) { return 'hourly' } + if (isDailyVersion(normalized)) { + return 'daily' + } if (isAdhocVersion(normalized)) { return 'adhoc' }