Files
orca/config/scripts/setup-hourly-release-token.sh
T
Neil f998f7ec62 feat(updater): add hourly dev channel and build switching (#11250)
* feat(updater): add hourly dev channel and build switching

Adds an hourly macOS build channel plus a dev-only surface for switching
update channels and jumping to any published build, including older ones.

Hourly builds publish to a separate stablyai/orca-hourly repo. The routine
update path resolves tags from the main repo's releases atom feed, which
exposes only its 10 newest entries — 24 hourly tags a day would evict every
stable/RC entry there and leave real users with nothing to update to.

Hourly artifacts carry the release bundle id and Developer ID signature so
Squirrel.Mac can swap them in place; only notarization is skipped, which
in-place updates never check.

Version tails are stripped to the base (1.4.160-hourly.<stamp>, not
1.4.160-rc.3-hourly.<stamp>) so hourlies sort below both rc.N and stable and
are reachable only by an explicit pinned jump, never by an ordinary check.

The picker is revealed by Option-clicking the Updates header, matching the
Help menu's existing hidden admin affordance. Pinned jumps set allowDowngrade
and release the feed on every settle path so a jump can never leave background
checks permanently deferred.

* chore(hourly): create orca-hourly and add token provisioning script

Adds setup-hourly-release-token.sh, which provisions HOURLY_RELEASE_TOKEN
without the value ever reaching stdout, argv, or shell history: it is read
with `read -rs`, passed to gh through GH_TOKEN in the environment rather than
as an argument (argv is world-readable via ps), piped into `gh secret set` on
stdin, and scrubbed by an EXIT trap.

Verification creates and deletes a draft release in orca-hourly to prove
Contents:write for real rather than trusting the permission checkbox. Drafts
are absent from the releases atom feed, so the probe cannot disturb users.

Refuses to run without a controlling terminal instead of falling through
having set nothing, and refuses to run under xtrace, which would echo the
token on every expansion.

* fix(updater): address review feedback on the hourly channel

Renderer:
- Guard listBuilds against out-of-order responses. activeChannel flips once
  getVersion resolves, and rapid channel clicks stack requests, so a slower
  earlier load could land last and fill the list with builds from a channel
  the picker was no longer showing.
- Selecting the running build's own channel now clears the override instead
  of pinning it. There was previously no way back to "follow this build's
  channel", so merely opening the panel left background checks pinned.
- Validate releaseChannelOverride on hydration, matching every other
  enum-like field in that function.

Main:
- Exclude pinned jumps from recordCompletedUpdateCheck() in update-available.
  A dev browsing the picker was persisting lastUpdateCheckAt and suppressing
  the next real background check for a full day.
- parseHourlyVersionStamp now anchors on the whole version and round-trips
  the parsed fields. It accepted garbage prefixes, and Date.UTC rolled
  impossible dates forward, so ...hourly.202602300000 rendered as March 2.

Workflow:
- Publish into a draft and flip it live only after the manifest check. The
  window between creating the release and verifying its assets previously
  exposed a tag the picker would offer and the download would 404 on; a
  draft is invisible to listReleaseBuilds, so a job that dies in that
  window — including a hard kill by the job timeout, which runs no cleanup
  step — leaves nothing user-visible behind.
- Add a failure handler that discards the draft, gated on the publish step
  not having succeeded so a later prune failure cannot delete a live release.
- Align retry budgets with the job timeout (was 60min against a worst case
  of ~185min, so a mid-retry kill skipped the cleanup that step exists for).
- Exclude drafts from the freshness and retention queries.
- persist-credentials: false; the job only reads this repo and never pushes.

* refactor(hourly): authenticate with a GitHub App instead of a PAT

A fine-grained PAT expires, and the hourly build would then fail silently on
a schedule nobody watches. A GitHub App's private key has no expiry, so this
is set up once. It is also owned by the org rather than by the person who
created it, so the credential survives that person leaving.

The workflow mints a short-lived installation token via
actions/create-github-app-token and passes it as GH_TOKEN. Installation
tokens live one hour, which is ample: this job runs no tests, no
notarization, and no Windows signing, so it is pack + upload. The retry
budgets and job timeout are re-sized to that reality rather than copied from
the release pipeline, whose 3x45 publish budget exists for notarization and
SignPath.

setup-hourly-release-token.sh now provisions HOURLY_RELEASE_APP_ID and
HOURLY_RELEASE_APP_PRIVATE_KEY. The key is redirected from a file straight
into `gh secret set` on stdin, so its contents never enter a shell variable,
argv, or the terminal.

* fix(hourly): make the xtrace guard fire and cover cancelled runs

The xtrace guard disabled tracing before testing for it, so `[[ -o xtrace ]]`
read the state the previous line had just cleared and never fired. `bash -x`
ran straight through, tracing exactly the key handling the guard exists to
prevent. Test first, then disable.

The draft cleanup only ran on failure(), but a run stopped from the Actions
UI is cancelled(), not failed — a manual cancel mid-publish stranded the
draft. Cover both.
2026-07-30 22:53:02 -07:00

96 lines
3.8 KiB
Bash
Executable File

#!/usr/bin/env bash
#
# Provisions the credentials hourly-mac-build.yml uses to publish into
# stablyai/orca-hourly. GITHUB_TOKEN cannot be used: it is scoped to the repo
# running the workflow, and hourly artifacts are published to a different one.
#
# A GitHub App is used rather than a PAT because its private key does not expire
# — no yearly rotation — and it belongs to the org rather than to the person who
# created it, so it survives that person leaving.
#
# 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
# `ps`), and never copied anywhere on disk.
#
# Usage: bash config/scripts/setup-hourly-release-token.sh [path/to/key.pem]
#
set -euo pipefail
# Guard: xtrace would echo the key to stderr on every expansion. Test before
# disabling, or the check reads the state this line just cleared and never fires.
if [[ -o xtrace ]]; then
echo "Refusing to run with xtrace enabled; it would echo the private key." >&2
exit 1
fi
set +x
MAIN_REPO="stablyai/orca"
HOURLY_REPO="stablyai/orca-hourly"
APP_ID_SECRET="HOURLY_RELEASE_APP_ID"
APP_KEY_SECRET="HOURLY_RELEASE_APP_PRIVATE_KEY"
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"
# Setting repo secrets requires admin; check before asking for anything.
if [[ "$(gh api "repos/$MAIN_REPO" --jq '.permissions.admin' 2>/dev/null)" != "true" ]]; then
fail "You need admin on $MAIN_REPO to set repository secrets."
fi
gh api "repos/$HOURLY_REPO" --jq '.full_name' >/dev/null 2>&1 ||
fail "$HOURLY_REPO does not exist or you cannot see it."
cat <<EOF
Create a GitHub App (one time — the key never expires)
──────────────────────────────────────────────────────
1. Open: https://github.com/organizations/stablyai/settings/apps/new
2. Name .................. orca-hourly-release
Homepage URL .......... https://github.com/$HOURLY_REPO
Webhook ............... UNCHECK "Active"
3. Repository permissions -> Contents: Read and write
(leave everything else alone)
4. "Where can this app be installed?" -> Only on this account
5. Create, then note the App ID shown at the top of the page.
6. Generate a private key (bottom of the page) — a .pem downloads.
7. Install App -> Only select repositories -> $HOURLY_REPO
EOF
read -rp "App ID (numeric): " APP_ID
[[ "$APP_ID" =~ ^[0-9]+$ ]] || fail "App ID must be numeric, got: ${APP_ID:-<empty>}"
KEY_PATH="${1:-}"
if [[ -z "$KEY_PATH" ]]; then
read -rp "Path to the downloaded .pem: " KEY_PATH
fi
# Expand a leading ~ so a pasted path works without quoting rules.
KEY_PATH="${KEY_PATH/#\~/$HOME}"
[[ -r "$KEY_PATH" ]] || fail "Cannot read key file: $KEY_PATH"
grep -q "BEGIN.*PRIVATE KEY" "$KEY_PATH" ||
fail "$KEY_PATH does not look like a PEM private key."
echo "Storing $APP_ID_SECRET in $MAIN_REPO..."
printf '%s' "$APP_ID" | gh secret set "$APP_ID_SECRET" --repo "$MAIN_REPO" ||
fail "Could not set $APP_ID_SECRET."
# Piped on stdin so the key never appears in argv or in shell history.
echo "Storing $APP_KEY_SECRET in $MAIN_REPO..."
gh secret set "$APP_KEY_SECRET" --repo "$MAIN_REPO" <"$KEY_PATH" ||
fail "Could not set $APP_KEY_SECRET."
echo
echo "Done. Both secrets are set on $MAIN_REPO."
echo
echo "Delete your local copy of the key — the workflow reads it from the secret,"
echo "and a .pem sitting in ~/Downloads is a standing credential:"
echo " rm '$KEY_PATH'"
echo
echo "Smoke-test the pipeline without waiting for the hour (after this merges):"
echo " gh workflow run hourly-mac-build.yml --repo $MAIN_REPO -f force=true"
echo " gh run watch --repo $MAIN_REPO"