Skip to content

feat(apple-auth): web Sign in with Apple — backend + domain (dark) (#… #228

feat(apple-auth): web Sign in with Apple — backend + domain (dark) (#…

feat(apple-auth): web Sign in with Apple — backend + domain (dark) (#… #228

Workflow file for this run

name: release
# release-please (the pinned action on the workflow's own GITHUB_TOKEN — no
# GitHub App, no minted tokens) maintains a release PR (version bump +
# CHANGELOG + every derived version stamp via extra-files) from
# conventional-commit history. Merging it lands the bumped manifest on main;
# the release job below cuts the vX.Y.Z tag + GitHub Release itself and the
# pipeline ships: gate at the tag, then every artifact builds at once — macOS,
# iOS, Mac App Store, node — alongside the terraform apply and the Lambda
# deploys. Publishing is what waits: the macOS bundle + updater manifest attach
# only once the backend is green, and the TestFlight uploads (both platforms,
# one app record) and node assets follow behind that. Apple artifacts are also
# rebuildable on demand for an existing tag via workflow_dispatch.
on:
push:
branches: [main]
workflow_dispatch:
inputs:
tag:
description: "Existing release tag to build the Apple artifacts for: attaches the macOS bundle, re-uploads iOS + Mac App Store builds to TestFlight (e.g. v0.1.0)"
required: true
permissions: {}
# Strictly one release run at a time, never cancelled: back-to-back merges to
# main once raced two runs, and the EARLIER commit's run cut the release — so
# its gates ran one commit behind the tag. Queueing serializes them, the cut
# step tags only its own commit, and the tag-pinned checkouts below make
# whichever run ships a tag gate/build that tag itself.
concurrency:
group: release-${{ github.ref }}
cancel-in-progress: false
jobs:
release:
if: ${{ github.event_name == 'push' }}
runs-on: ubuntu-latest
timeout-minutes: 10
permissions:
contents: write # release-please's branch pushes + the tag/release cut below
pull-requests: write # release-please's PR bookkeeping + the label flip
actions: write # approve the release branch's held gate runs
outputs:
release_created: ${{ steps.effective.outputs.release_created }}
tag_name: ${{ steps.effective.outputs.tag_name }}
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
# Maintains the release PR (version bump + CHANGELOG + every version
# stamp via extra-files, one generated commit) on the workflow's own
# GITHUB_TOKEN — no GitHub App, no minted tokens. Its branch pushes DO
# start desktop.yml runs, held in the action_required state and approved
# below; desktop.yml's per-job guard then skips their work, because the
# release PR carries nothing main's tip was not already gated against.
# The authoritative gate is the `ci` job below, run against the exact
# release commit before anything ships. skip-github-release in the config
# keeps the action away from tagging — the cut step below owns that.
- uses: googleapis/release-please-action@45996ed1f6d02564a971a2fa1b5860e934307cf7 # v5
with:
config-file: release-please-config.json
manifest-file: .release-please-manifest.json
token: ${{ github.token }}
# Loud tripwire, not a silent fix: while a release PR is open, its
# branch must carry every version stamp the extra-files above are
# configured to write. A mismatch means the updaters and the file
# shapes drifted apart — fail this run on main where it is impossible
# to miss, instead of letting an unstamped Cargo.lock surface as a
# --locked failure halfway through shipping a release.
- name: Verify release-branch version stamps
env:
BRANCH: release-please--branches--main--components--lux
run: |
set -euo pipefail
if ! git ls-remote --exit-code origin "refs/heads/$BRANCH" >/dev/null 2>&1; then
echo "no open release branch; nothing to verify"
exit 0
fi
git fetch --depth=1 origin "$BRANCH"
# FETCH_HEAD, not origin/<branch>: it exists regardless of the
# checkout's fetch refspec. The awk matchers scan to end of stream —
# an early exit SIGPIPEs git show under pipefail (exit 141).
tree=FETCH_HEAD
want=$(git show "$tree:.release-please-manifest.json" | jq -r '."."')
lock=$(git show "$tree:Cargo.lock" | awk '/^name = "lux"$/ {getline; sub(/^version = "/, ""); sub(/"$/, ""); print}')
plist=$(git show "$tree:apps/desktop/src-tauri/gen/apple/lux_iOS/Info.plist" | awk '/CFBundleShortVersionString/ {getline; gsub(/[[:space:]]*<\/?string>/, ""); print}')
echo "release branch wants $want (Cargo.lock: $lock, Info.plist: $plist)"
[ "$lock" = "$want" ] && [ "$plist" = "$want" ] \
|| { echo "::error::release branch stamps out of sync with $want — check the extra-files updaters in release-please-config.json"; exit 1; }
# A GITHUB_TOKEN branch push creates the release PR's gate run in the
# action_required state (GitHub holds workflow runs for events the token
# caused; pull_request branch filters can't exclude a head branch, they
# match the base). Approve the held run so the release PR carries a
# concluded gate instead of an "awaiting approval" banner — the run
# itself is near-free, since desktop.yml's per-job guard skips the work
# for this branch and the tag gate below is what actually rules — but ONLY
# the branch tip's run: the PR's gate runs share one concurrency group,
# so approving a superseded head's run alongside the live one lets the
# loser cancel the winner. Stale held runs are deleted instead (their
# commit is gone from the branch; they can never matter). Fail-soft
# throughout: an unapproved run is cosmetic — merging is not blocked
# and the authoritative gate is the ci job at the tag.
- name: Approve the release branch's held gate run
env:
GH_TOKEN: ${{ github.token }}
BRANCH: release-please--branches--main--components--lux
run: |
tip=$(git ls-remote origin "refs/heads/$BRANCH" | cut -f1)
if [ -z "$tip" ]; then
echo "no open release branch; nothing to approve"
exit 0
fi
approved=""
for i in 1 2 3; do
sleep 10
id=$(gh run list --branch "$BRANCH" --status action_required --limit 10 --json databaseId,headSha \
--jq ".[] | select(.headSha==\"$tip\") | .databaseId" | head -1)
if [ -n "$id" ]; then
gh api -X POST "repos/${GITHUB_REPOSITORY}/actions/runs/$id/approve" >/dev/null \
&& { approved=1; echo "approved held run $id (tip ${tip:0:7})"; } \
|| echo "::warning::could not approve held run $id (cosmetic; the tag gate still rules)"
break
fi
done
[ -n "$approved" ] || echo "no held run for the branch tip (nothing newly pushed, or it already ran)"
gh run list --branch "$BRANCH" --status action_required --limit 10 --json databaseId,headSha \
--jq ".[] | select(.headSha!=\"$tip\") | .databaseId" \
| while read -r stale; do
gh api -X DELETE "repos/${GITHUB_REPOSITORY}/actions/runs/$stale" >/dev/null \
&& echo "deleted stale held run $stale" \
|| echo "::warning::could not delete stale held run $stale"
done
# The pipeline keys off observable repo state, never a tool's memory of
# what it did. The manifest names the current version; from there: no
# release for it yet means this push merged the release PR — cut the
# tag + an assetless release at THIS run's commit (never "latest main":
# a queued run must not tag a commit its gates didn't see); an assetless
# release means a prior attempt died before the artifacts landed — ship
# it, whoever created it; a release WITH assets is finished and is
# never rebuilt (re-signing an already-served tag is the known
# updater-breaker).
- name: Resolve or cut the release (from repo state)
id: effective
env:
GH_TOKEN: ${{ github.token }}
run: |
set -euo pipefail
version=$(jq -r '."."' .release-please-manifest.json)
tag="v$version"
if assets=$(gh release view "$tag" --json assets --jq '.assets | length' 2>/dev/null); then
if [ "$assets" = "0" ]; then
echo "::notice::shipping assetless release $tag (attempt ${{ github.run_attempt }})"
{ echo "release_created=true"; echo "tag_name=$tag"; } >> "$GITHUB_OUTPUT"
else
echo "$tag is fully released; nothing to ship"
{ echo "release_created=false"; echo "tag_name="; } >> "$GITHUB_OUTPUT"
fi
exit 0
fi
# The version's CHANGELOG section becomes the release body, matching
# the releases release-please used to publish.
notes=$(awk -v ver="$version" '
index($0, "## [" ver "]") == 1 { grab = 1; next }
grab && index($0, "## [") == 1 { exit }
grab { print }
' CHANGELOG.md)
gh release create "$tag" --target "$GITHUB_SHA" --title "$tag" --notes "${notes:-$tag}"
echo "::notice::cut $tag at ${GITHUB_SHA:0:7}"
{ echo "release_created=true"; echo "tag_name=$tag"; } >> "$GITHUB_OUTPUT"
# Tagging is what used to flip the release PR's label; with tagging done
# here, flip it here too, so merged release PRs don't linger "pending"
# (and the action's pending-PR scans stay empty). Cosmetic — never fatal.
- name: Flip the release PR label (pending → tagged)
if: ${{ steps.effective.outputs.release_created == 'true' }}
env:
GH_TOKEN: ${{ github.token }}
run: |
for n in $(gh pr list --state merged --label "autorelease: pending" --json number --jq '.[].number'); do
gh pr edit "$n" --remove-label "autorelease: pending" --add-label "autorelease: tagged" \
|| echo "::warning::could not flip the autorelease label on PR #$n"
done
# Draft the App Store "What's New" for the version just cut and attach it to
# the GitHub Release body inside <!-- store-notes --> markers (invisible on
# the rendered page) — no commit to main, nothing to merge. The draft is
# write-once: a release whose body already carries a section is never
# redrafted. Edit the section on the release page any time before
# appstore.yml submits the version (it extracts the section and refuses to
# submit without one); delete the section and re-run this job for a fresh
# draft.
store-notes:
needs: release
if: ${{ needs.release.outputs.release_created == 'true' }}
runs-on: ubuntu-latest
timeout-minutes: 10
permissions:
contents: write # edit the release body
id-token: write # AWS OIDC, to read the Anthropic key
env:
TAG: ${{ needs.release.outputs.tag_name }}
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
with:
ref: ${{ env.TAG }}
- uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2
with:
bun-version-file: apps/desktop/package.json
- run: bun install --frozen-lockfile
- uses: aws-actions/configure-aws-credentials@517a711dbcd0e402f90c77e7e2f81e849156e31d # v6
with:
role-to-assume: arn:aws:iam::735853783919:role/lux-release-signing
aws-region: us-west-1
- name: Draft the notes and attach them to the release (write-once)
env:
GH_TOKEN: ${{ github.token }}
run: |
set -euo pipefail
version="${TAG#v}"
if gh release view "$TAG" --json body --jq .body | grep -q '<!-- store-notes -->'; then
echo "release $TAG already carries store notes; leaving them alone"
exit 0
fi
# Voice anchor for the drafter: the newest earlier release carrying
# a section; the script falls back to the notes files committed
# before this flow existed when none is found.
for prev in $(gh release list --limit 6 --json tagName --jq ".[].tagName" | grep -vx "$TAG"); do
gh release view "$prev" --json body --jq .body \
| awk '/<!-- store-notes -->/{grab=1; next} /<!-- \/store-notes -->/{exit} grab{print}' \
> "$RUNNER_TEMP/prev-notes.md" || true
if grep -q . "$RUNNER_TEMP/prev-notes.md"; then
echo "voice anchor: $prev"
break
fi
done
key=$(aws secretsmanager get-secret-value --secret-id lux/anthropic-api-key --query SecretString --output text)
echo "::add-mask::$key"
PREV_NOTES_FILE="$RUNNER_TEMP/prev-notes.md" ANTHROPIC_API_KEY="$key" bun scripts/draft-store-notes.ts
file="apps/desktop/store-notes/$version.md"
[ -s "$file" ] || { echo "no draft produced (no changelog section?); nothing to attach"; exit 0; }
body=$(gh release view "$TAG" --json body --jq .body)
printf '%s\n\n<!-- store-notes -->\n%s\n<!-- /store-notes -->\n' "$body" "$(cat "$file")" \
| gh release edit "$TAG" --notes-file -
echo "attached store notes to $TAG"
# The release gate: the whole `desktop` workflow (tauri-versions, frontend
# build/typecheck/lint, cargo test and cargo clippy across the workspace, the
# Lambda musl build, cargo-deny), run against the exact release commit — and
# the only gate that commit gets, since its push-triggered run skips itself
# rather than duplicate this one. Everything that ships — infra, Lambda code,
# the desktop bundle — is downstream of this job, so a red release commit
# cannot release.
ci:
needs: release
if: ${{ needs.release.outputs.release_created == 'true' }}
permissions:
contents: read
uses: ./.github/workflows/desktop.yml
with:
# Gate the tag itself, not this run's triggering sha — the two can differ
# when a race let an earlier push's run cut the release.
ref: ${{ needs.release.outputs.tag_name }}
# Builds, signs, and notarizes the macOS app and stages the bundles as a
# workflow artifact — in parallel with the backend and the other build legs,
# since staging touches nothing anyone can see. This is the long pole of the
# whole pipeline, so it starts the moment the gate is green rather than
# waiting out the backend it doesn't depend on. Attaching the bundles (and
# the updater's latest.json) to the release is publish-macos's job, and that
# is what waits: publishing the updater manifest is what makes a release
# visible to users, so a release whose backend didn't ship never reaches
# them. Manual dispatch rebuilds an existing tag whose backend already
# shipped, so it skips those gates.
build-macos:
needs: [release, ci]
if: ${{ always() && (github.event_name == 'workflow_dispatch' || (needs.release.outputs.release_created == 'true' && needs.ci.result == 'success')) }}
runs-on: macos-latest
timeout-minutes: 60
permissions:
contents: read
id-token: write # AWS OIDC, to read the updater + Apple signing keys
outputs:
version: ${{ steps.tauri.outputs.appVersion }}
env:
TAG: ${{ github.event.inputs.tag || needs.release.outputs.tag_name }}
RUSTC_WRAPPER: sccache
SCCACHE_GHA_ENABLED: "true"
CARGO_INCREMENTAL: "0"
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
with:
ref: ${{ env.TAG }}
# A dispatch rebuild of a tag whose macOS artifacts already shipped would
# re-sign and replace the served .dmg + latest.json — fresh signatures
# for a tag existing installs already fetched, the known updater-breaker.
# Refuse loudly: partial rebuilds belong to `gh run rerun <id> --failed`
# on the original release run (it never re-runs succeeded jobs), and a
# deliberate macOS rebuild means deleting the release's macOS assets
# first. Assetless tags (a run that died before publishing) pass. The
# check guards publish-macos but lives here, where refusing costs a
# checkout instead of a full signed-and-notarized build.
- name: Refuse to rebuild served macOS artifacts (dispatch)
if: ${{ github.event_name == 'workflow_dispatch' }}
env:
GH_TOKEN: ${{ github.token }}
run: |
if gh release view "$TAG" --json assets --jq '.assets[].name' | grep -qE '\.dmg$|^latest\.json$'; then
echo "::error::$TAG already serves macOS artifacts — use 'gh run rerun <run-id> --failed' on the original release run, or delete the release's macOS assets first to rebuild deliberately."
exit 1
fi
echo "$TAG has no served macOS artifacts; rebuild is safe"
- uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2
with:
bun-version-file: apps/desktop/package.json
- uses: dtolnay/rust-toolchain@4be7066ada62dd38de10e7b70166bc74ed198c30 # stable branch
with:
targets: aarch64-apple-darwin,x86_64-apple-darwin
# Per-crate compilation cache (GitHub Actions backend), supplementing
# rust-cache: it still hits across a Cargo.lock bump for the universal
# build's per-arch dependency compilation, though the final fat-LTO link
# itself isn't cacheable.
- uses: mozilla-actions/sccache-action@9e7fa8a12102821edf02ca5dbea1acd0f89a2696 # v0.0.10
# Cache the compiled dependency tree. ~90% of this build is dependencies
# compiled from scratch, twice (once per arch), under the deliberately
# slow release profile (codegen-units = 1 + fat LTO — Tauri's recommended
# smallest-binary config, kept as-is). rust-cache restores those dep
# artifacts keyed on Cargo.lock, so only the workspace crates + the final
# LTO link recompile. The cache is scoped to the workspace-root target/.
- uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 # v2.9.1
with:
workspaces: "."
- run: bun install --frozen-lockfile
working-directory: apps/desktop
# Pull the updater signing key from AWS Secrets Manager via OIDC — no
# long-lived AWS keys in GitHub. Written to a file so it never hits a log.
- uses: aws-actions/configure-aws-credentials@517a711dbcd0e402f90c77e7e2f81e849156e31d # v6
with:
role-to-assume: arn:aws:iam::735853783919:role/lux-release-signing
aws-region: us-west-1
- name: Load updater signing key
# The secret is JSON {key, password}. Write the key to a file (printf '%s'
# avoids a trailing newline that would break the base64 decode) and pass
# the password via $GITHUB_ENV, masked so it never appears in logs.
run: |
secret=$(aws secretsmanager get-secret-value --secret-id lux/updater-signing-key --query SecretString --output text)
printf '%s' "$(printf '%s' "$secret" | jq -r '.key')" > "$RUNNER_TEMP/updater.key"
password=$(printf '%s' "$secret" | jq -r '.password')
echo "::add-mask::$password"
echo "TAURI_SIGNING_PRIVATE_KEY_PASSWORD=$password" >> "$GITHUB_ENV"
- name: Load Apple signing secret
# JSON {certificate (.p12 b64), certificate_password, signing_identity,
# team_id, api_key (.p8), api_key_id, api_issuer_id} -> tauri-action env.
# tauri-action reads APPLE_* from the process env (set via $GITHUB_ENV).
run: |
s=$(aws secretsmanager get-secret-value --secret-id lux/apple-signing --query SecretString --output text)
# Mask everything sensitive that rides $GITHUB_ENV — the runner echoes
# accumulated env in later steps' log groups (the .p12 is what leaked).
echo "::add-mask::$(printf '%s' "$s" | jq -r '.certificate')"
echo "::add-mask::$(printf '%s' "$s" | jq -r '.certificate_password')"
echo "::add-mask::$(printf '%s' "$s" | jq -r '.api_key_id')"
echo "::add-mask::$(printf '%s' "$s" | jq -r '.api_issuer_id')"
{
echo "APPLE_CERTIFICATE=$(printf '%s' "$s" | jq -r '.certificate')"
echo "APPLE_CERTIFICATE_PASSWORD=$(printf '%s' "$s" | jq -r '.certificate_password')"
echo "APPLE_SIGNING_IDENTITY=$(printf '%s' "$s" | jq -r '.signing_identity')"
echo "APPLE_TEAM_ID=$(printf '%s' "$s" | jq -r '.team_id')"
echo "APPLE_API_ISSUER=$(printf '%s' "$s" | jq -r '.api_issuer_id')"
echo "APPLE_API_KEY=$(printf '%s' "$s" | jq -r '.api_key_id')"
} >> "$GITHUB_ENV"
printf '%s' "$s" | jq -r '.api_key' > "$RUNNER_TEMP/asc_api_key.p8"
echo "APPLE_API_KEY_PATH=$RUNNER_TEMP/asc_api_key.p8" >> "$GITHUB_ENV"
# Deliberately no tagName and no releaseId: with neither, the action
# builds and signs but uploads nothing ("skipping all uploads"), which is
# the whole point of splitting this job — the artifacts must exist before
# the backend is green, and be publishable only after. It still reports
# artifactPaths and appVersion, which is everything publish-macos needs.
- uses: tauri-apps/tauri-action@1deb371b0cd8bd54025b384f1cd735e725c4060f # v1.0.0
id: tauri
env:
TAURI_SIGNING_PRIVATE_KEY: ${{ runner.temp }}/updater.key
with:
projectPath: apps/desktop
args: --target universal-apple-darwin
# Stage the bundles under exactly the names the action's own uploader
# would have given them, so the published assets are unchanged: the .dmg
# keeps the filename tauri's CLI chose, while the updater tarball and its
# detached signature gain the version and arch the CLI leaves off.
# artifactPaths also lists the unpackaged lux.app directory — the suffix
# matching below picks the three shippable files out of it.
- name: Stage the signed bundles
env:
PATHS: ${{ steps.tauri.outputs.artifactPaths }}
VERSION: ${{ steps.tauri.outputs.appVersion }}
run: |
set -euo pipefail
pick() { jq -r --arg ext "$1" '[.[] | select(endswith($ext))] | first // ""' <<<"$PATHS"; }
dmg=$(pick .dmg)
tarball=$(pick .app.tar.gz)
sig=$(pick .app.tar.gz.sig)
[ -s "$dmg" ] && [ -s "$tarball" ] && [ -s "$sig" ] \
|| { echo "::error::expected a .dmg, a .app.tar.gz and its .sig among $PATHS"; exit 1; }
out="$RUNNER_TEMP/macos"
mkdir -p "$out"
cp "$dmg" "$out/$(basename "$dmg")"
cp "$tarball" "$out/lux_${VERSION}_universal.app.tar.gz"
cp "$sig" "$out/lux_${VERSION}_universal.app.tar.gz.sig"
ls -l "$out"
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: macos-bundles
path: ${{ runner.temp }}/macos
retention-days: 1
- name: sccache stats
if: ${{ always() }}
run: sccache --show-stats
# The publish half of the macOS build, and the gate the rest of the release
# waits behind. The signed, notarized bundles already exist; this attaches
# them to the release and writes the updater's latest.json last, only once
# the Lambdas have deployed and passed their smokes. That ordering is the
# load-bearing part: latest.json is the endpoint every installed copy polls,
# so a release whose backend didn't ship can never reach one.
publish-macos:
needs: [release, build-macos, deploy-lambdas]
if: ${{ always() && needs.build-macos.result == 'success' && (github.event_name == 'workflow_dispatch' || (needs.release.outputs.release_created == 'true' && needs.deploy-lambdas.result == 'success')) }}
runs-on: ubuntu-latest
timeout-minutes: 10
permissions:
contents: write # attach the release assets
deployments: write # record the updater publish (API, not environment: — see the record step)
env:
TAG: ${{ github.event.inputs.tag || needs.release.outputs.tag_name }}
VERSION: ${{ needs.build-macos.outputs.version }}
steps:
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: macos-bundles
path: ${{ runner.temp }}/macos
# The tag names the release; the version stamps the bundles and the
# manifest. They reach this job from opposite ends of the pipeline —
# release-please's manifest versus the tauri config the build read — so
# a disagreement means a stamp drifted, and publishing across it would
# serve an updater manifest for a version nobody can download.
- name: Verify the built version matches the tag
run: |
test "v$VERSION" = "$TAG" \
|| { echo "::error::built version $VERSION does not match release tag $TAG"; exit 1; }
- name: Attach the bundles to the release
env:
GH_TOKEN: ${{ github.token }}
run: |
set -euo pipefail
cd "$RUNNER_TEMP/macos"
ls -l
gh release upload "$TAG" ./* --clobber -R "$GITHUB_REPOSITORY"
# tauri-action wrote this file back when it owned the upload; it is
# reproduced here because publishing now happens in a job that never
# builds. This is its output for a single universal darwin bundle: every
# darwin key — the bare ones and the newer -app suffixed ones — points at
# the one .app.tar.gz through its REST asset URL and carries that
# bundle's detached signature. Written last, because this file going up
# is what "released" means.
- name: Publish the updater manifest
env:
GH_TOKEN: ${{ github.token }}
run: |
set -euo pipefail
staged="$RUNNER_TEMP/macos"
tarball="lux_${VERSION}_universal.app.tar.gz"
id=$(gh api "repos/$GITHUB_REPOSITORY/releases/tags/$TAG" \
--jq ".assets[] | select(.name == \"$tarball\") | .id")
[ -n "$id" ] || { echo "::error::$tarball is not attached to $TAG"; exit 1; }
url="$GITHUB_API_URL/repos/$GITHUB_REPOSITORY/releases/assets/$id"
jq -n --arg version "$VERSION" \
--arg pub_date "$(date -u +%Y-%m-%dT%H:%M:%S.000Z)" \
--arg signature "$(cat "$staged/$tarball.sig")" \
--arg url "$url" \
'{version: $version, notes: "", pub_date: $pub_date,
platforms: (["darwin-aarch64", "darwin-x86_64", "darwin-universal",
"darwin-aarch64-app", "darwin-x86_64-app", "darwin-universal-app"]
| map({key: ., value: {signature: $signature, url: $url}})
| from_entries)}' > "$RUNNER_TEMP/latest.json"
jq '.platforms | keys' "$RUNNER_TEMP/latest.json"
# A manifest is worth exactly what the URL inside it resolves to, so
# fetch the bundle back through that URL before making it the served
# endpoint. Authenticated only to stay off the anonymous API rate
# limit that runner IPs share — the asset itself is public.
code=$(curl -sSL -o /dev/null -w '%{http_code}' \
-H 'Accept: application/octet-stream' -H "Authorization: Bearer $GH_TOKEN" "$url")
test "$code" = "200" || { echo "::error::the updater's asset URL answered $code"; exit 1; }
gh release upload "$TAG" "$RUNNER_TEMP/latest.json" --clobber -R "$GITHUB_REPOSITORY"
echo "published latest.json for $TAG"
# Record what is live in the repo's Deployments view. Done via the API,
# NOT a job-level `environment:` key — that key rewrites the OIDC subject
# to repo:…:environment:…, which the role trusts (pinned to the main ref)
# would reject, breaking the AWS credentials the build job used.
- name: Record updater deployment
env:
GH_TOKEN: ${{ github.token }}
run: |
set -euo pipefail
body=$(jq -nc --arg ref "$GITHUB_SHA" --arg desc "$TAG" \
'{ref:$ref, environment:"updater", production_environment:true, auto_merge:false, required_contexts:[], description:$desc}')
id=$(gh api -X POST "repos/$GITHUB_REPOSITORY/deployments" --input - <<<"$body" --jq '.id')
gh api -X POST "repos/$GITHUB_REPOSITORY/deployments/$id/statuses" \
-f state=success -f environment=updater -f environment_url=https://github.com/johncarmack1984/lux/releases/latest \
-f description="artifacts + latest.json published for $TAG" >/dev/null
echo "recorded updater deployment $id ($TAG)"
# Apply the infra when a release is cut — i.e. when the release-please
# version-bump PR is merged (release_created). This is the ONLY place
# `terraform apply` runs; never on a laptop. The lux-terraform-apply role's
# trust is scoped to refs/heads/main, so only this main-branch run can assume
# it. Skipped on workflow_dispatch (manual macOS rebuilds) and on pushes that
# don't cut a release. An infra PR therefore merges first and applies here with
# the next release — its diff was reviewed as the terraform.yml PR plan.
terraform-apply:
needs: [release, ci]
if: ${{ needs.release.outputs.release_created == 'true' && needs.ci.result == 'success' }}
runs-on: ubuntu-latest
timeout-minutes: 15
permissions:
contents: read
id-token: write # OIDC: assume the apply role
deployments: write # record the infra deployment
defaults:
run:
working-directory: infra
steps:
# The tag, not github.sha — the infra applied must be exactly the infra
# the release's endpoints file and binaries were built against.
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
with:
ref: ${{ needs.release.outputs.tag_name }}
- uses: aws-actions/configure-aws-credentials@517a711dbcd0e402f90c77e7e2f81e849156e31d # v6
with:
role-to-assume: arn:aws:iam::735853783919:role/lux-terraform-apply
aws-region: us-west-1
- uses: hashicorp/setup-terraform@dfe3c3f87815947d99a8997f908cb6525fc44e9e # v4
with:
terraform_version: 1.15.7
- name: init
run: terraform init -input=false
# One automatic retry: when an apply expands the apply role's own inline
# policy and creates resources needing the new grants in the same run,
# IAM propagation can lose the race. terraform apply is idempotent, so
# the second pass just finishes whatever the first couldn't.
- name: apply
run: |
terraform apply -auto-approve -no-color -input=false || {
echo "::warning::apply failed once; retrying after IAM propagation delay"
sleep 20
terraform apply -auto-approve -no-color -input=false
}
# Post-apply drift gate: if this apply changed an app-facing endpoint
# value, the tag's embedded endpoints.prod.json no longer matches live
# infra — block the Lambdas and artifacts (the desktop would ship pointing
# somewhere stale). Recovery: run scripts/gen-endpoints, commit, release
# again. Endpoint-changing applies are rare (resource recreation); every
# other release sails through.
- name: endpoints drift gate
working-directory: ${{ github.workspace }}
run: |
scripts/gen-endpoints
git diff --exit-code -- apps/desktop/src-tauri/endpoints.prod.json \
|| { echo "::error::this apply changed app-facing endpoints; regenerate endpoints.prod.json, commit, and release again."; exit 1; }
# Record what is live in the repo's Deployments view. Done via the API,
# NOT a job-level `environment:` key — that key rewrites the OIDC subject
# to repo:…:environment:…, which the role trusts (pinned to the main ref)
# would reject, breaking the AWS credentials above.
- name: Record infra deployment
env:
GH_TOKEN: ${{ github.token }}
TAG: ${{ needs.release.outputs.tag_name }}
run: |
set -euo pipefail
body=$(jq -nc --arg ref "$GITHUB_SHA" --arg desc "$TAG" \
'{ref:$ref, environment:"infra", production_environment:true, auto_merge:false, required_contexts:[], description:$desc}')
id=$(gh api -X POST "repos/$GITHUB_REPOSITORY/deployments" --input - <<<"$body" --jq '.id')
gh api -X POST "repos/$GITHUB_REPOSITORY/deployments/$id/statuses" \
-f state=success -f environment=infra \
-f description="terraform applied for $TAG" >/dev/null
echo "recorded infra deployment $id ($TAG)"
# Ship the Lambda code — every release, from the release tag, after the infra
# is in place. Deploying all three every time (even when only one changed)
# keeps deployed code == main with no path-filter guesswork: workspace crates
# like lux-wire feed all of them, and a redeploy of unchanged code is a no-op.
# Each function then gets a smoke test that proves the new code actually cold
# starts and answers before the desktop artifacts are allowed to publish.
deploy-lambdas:
needs: [release, terraform-apply]
if: ${{ needs.release.outputs.release_created == 'true' && needs.terraform-apply.result == 'success' }}
runs-on: ubuntu-latest
timeout-minutes: 30
permissions:
contents: read
deployments: write # record the backend deployment
id-token: write # OIDC: assume the lambda-deploy role
env:
TAG: ${{ needs.release.outputs.tag_name }}
RUSTC_WRAPPER: sccache
SCCACHE_GHA_ENABLED: "true"
CARGO_INCREMENTAL: "0"
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
with:
ref: ${{ env.TAG }}
# Static musl binaries: fully self-contained, so there is no glibc-version
# question on provided.al2023 at all — no cargo-lambda, no zig, just the
# same cargo the test gate uses, plus the musl target and musl-gcc for
# ring's C bits. Everything is pure Rust + ring, musl's happy path; if a
# C-heavy dependency ever lands, revisit (that's the honest trigger for a
# container-image build).
- uses: dtolnay/rust-toolchain@4be7066ada62dd38de10e7b70166bc74ed198c30 # stable branch
with:
targets: x86_64-unknown-linux-musl
- name: Install musl-gcc
run: sudo apt-get update && sudo apt-get install -y musl-tools
# Same caching pair as the desktop test gate: sccache owns per-crate
# compilation (content-addressed, survives lockfile bumps), rust-cache
# keeps the registry/index warm without caching target/.
- uses: mozilla-actions/sccache-action@9e7fa8a12102821edf02ca5dbea1acd0f89a2696 # v0.0.10
- uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 # v2.9.1
with:
workspaces: "."
cache-targets: "false"
# The role is created by the terraform-apply job that just ran, so on the
# very first release after it lands, STS may not see it instantly —
# retry the assume instead of failing the release.
- uses: aws-actions/configure-aws-credentials@517a711dbcd0e402f90c77e7e2f81e849156e31d # v6
with:
role-to-assume: arn:aws:iam::735853783919:role/lux-lambda-deploy
aws-region: us-west-1
retry-max-attempts: 6
- name: Build (static musl)
run: cargo build --release --target x86_64-unknown-linux-musl -p lux-sync-api -p lux-iot-authorizer -p lux-discord-bot -p lux-apple-auth
# provided.al2023 runs the zip's `bootstrap`; package each binary under
# that name and wait until Lambda finishes swapping code before smoking.
# Every deploy publishes a numbered version (rollback provenance). The
# sync-api, bot, and apple-auth serve traffic from $LATEST (their
# Function URLs are unqualified), so the code swap is their cutover; the
# authorizer serves through the `live` alias, so its new version takes no
# traffic until the flip step below — after its smoke has passed against
# that exact version.
- name: Deploy
run: |
for fn in lux-sync-api lux-iot-authorizer lux-discord-bot lux-apple-auth; do
staging=$(mktemp -d)
cp "target/x86_64-unknown-linux-musl/release/$fn" "$staging/bootstrap"
(cd "$staging" && zip -q9 function.zip bootstrap)
version=$(aws lambda update-function-code --function-name "$fn" \
--zip-file "fileb://$staging/function.zip" --publish --query 'Version' --output text)
aws lambda wait function-updated --function-name "$fn"
echo "deployed $fn as version $version"
if [ "$fn" = "lux-iot-authorizer" ]; then
echo "AUTH_VERSION=$version" >> "$GITHUB_ENV"
fi
done
- name: sccache stats
if: ${{ always() }}
run: sccache --show-stats
# Unauthenticated 401s are this API's health check: a correct 401 with the
# typed error body proves the new binary cold-started (its init fetches
# the Cognito JWKS before serving) and is routing requests.
- name: Smoke sync-api
run: |
url=$(aws lambda get-function-url-config --function-name lux-sync-api --query FunctionUrl --output text)
code=$(curl -s -o /tmp/body -w '%{http_code}' "${url}setups")
cat /tmp/body; echo
test "$code" = "401" && grep -q '"error"' /tmp/body
# Shared control's routes answer the same typed 401, which is the cheapest
# proof that the shares module shipped and is gated: an unauthenticated
# claim must never reach the code lookup, and an unrouted path would 404.
- name: Smoke sync-api shares routes
run: |
url=$(aws lambda get-function-url-config --function-name lux-sync-api --query FunctionUrl --output text)
code=$(curl -s -o /tmp/shares-list -w '%{http_code}' "${url}shares")
cat /tmp/shares-list; echo
test "$code" = "401" && grep -q '"error"' /tmp/shares-list
code=$(curl -s -o /tmp/shares-claim -w '%{http_code}' -X POST \
-H 'content-type: application/json' -d '{"code":"LUX-SMOKE-TEST0"}' "${url}shares/claim")
cat /tmp/shares-claim; echo
test "$code" = "401" && grep -q '"error"' /tmp/shares-claim
# The authorizer reads grants from DynamoDB now, and a missing table is
# not an error there — it degrades to "no connection is ever widened",
# which would look exactly like shared control quietly not working. Assert
# the applied configuration rather than waiting to discover it.
- name: Assert authorizer grant lookup is configured
run: |
table=$(aws lambda get-function-configuration --function-name lux-iot-authorizer \
--query 'Environment.Variables.DYNAMODB_TABLE' --output text)
echo "authorizer grant table: $table"
test "$table" = "lux-sync"
# A garbage identity token answering the typed 401 proves the new binary
# cold-started (its init fetches the Cognito JWKS before serving), routes
# /auth/apple, and rejects unverifiable tokens.
- name: Smoke apple-auth
run: |
url=$(aws lambda get-function-url-config --function-name lux-apple-auth --query FunctionUrl --output text)
code=$(curl -s -o /tmp/apple-auth-body -w '%{http_code}' -X POST -H 'content-type: application/json' \
-d '{"identityToken":"smoke-test-garbage","authorizationCode":"x","rawNonce":"y"}' "${url}auth/apple")
cat /tmp/apple-auth-body; echo
test "$code" = "401" && grep -q '"error"' /tmp/apple-auth-body
# Invoke the freshly published version directly (qualified) with a
# garbage token: a well-formed deny proves it cold-started (JWKS fetch)
# and rejects bad tokens — all before it takes any auth traffic.
- name: Smoke iot-authorizer (pre-flip, exact version)
run: |
aws lambda invoke --function-name lux-iot-authorizer \
--qualifier "$AUTH_VERSION" \
--cli-binary-format raw-in-base64-out \
--payload '{"token":"smoke-test-garbage","protocolData":{"http":{"headers":{}}}}' \
/tmp/authorizer.json >/dev/null
cat /tmp/authorizer.json; echo
grep -q '"isAuthenticated":false' /tmp/authorizer.json
# The smoked version goes live by repointing the alias IoT invokes
# through. Roll back by pointing it at the previous version (echoed
# below and in the job summary).
- name: Flip authorizer alias
run: |
prev=$(aws lambda get-alias --function-name lux-iot-authorizer \
--name live --query FunctionVersion --output text)
aws lambda update-alias --function-name lux-iot-authorizer \
--name live --function-version "$AUTH_VERSION" \
--query 'AliasArn' --output text
{
echo "authorizer \`live\` alias: v$prev → v$AUTH_VERSION"
echo '```'
echo "rollback: aws lambda update-alias --function-name lux-iot-authorizer --name live --function-version $prev"
echo '```'
} | tee -a "$GITHUB_STEP_SUMMARY"
# The bot verifies Discord's ed25519 signature before anything else, so an
# unsigned request answering 401 proves the new code is up and checking.
- name: Smoke bot
run: |
url=$(aws lambda get-function-url-config --function-name lux-discord-bot --query FunctionUrl --output text)
code=$(curl -s -o /dev/null -w '%{http_code}' -X POST -H 'content-type: application/json' -d '{}' "$url")
echo "bot answered $code"
test "$code" = "401"
# Record what is live in the repo's Deployments view. Done via the API,
# NOT a job-level `environment:` key — that key rewrites the OIDC subject
# to repo:…:environment:…, which the role trusts (pinned to the main ref)
# would reject, breaking the AWS credentials above.
- name: Record backend deployment
env:
GH_TOKEN: ${{ github.token }}
TAG: ${{ env.TAG }}
run: |
set -euo pipefail
body=$(jq -nc --arg ref "$GITHUB_SHA" --arg desc "$TAG" \
'{ref:$ref, environment:"backend", production_environment:true, auto_merge:false, required_contexts:[], description:$desc}')
id=$(gh api -X POST "repos/$GITHUB_REPOSITORY/deployments" --input - <<<"$body" --jq '.id')
gh api -X POST "repos/$GITHUB_REPOSITORY/deployments/$id/statuses" \
-f state=success -f environment=backend \
-f description="Lambdas deployed + smoked for $TAG" >/dev/null
echo "recorded backend deployment $id ($TAG)"
# Builds and signs the iOS App Store .ipa and stages it as a workflow
# artifact — in parallel with the backend (terraform + Lambdas), since a
# staged artifact touches nothing. The ASC UPLOAD is deliberately deferred
# to publish-ios: TestFlight has no draft state, so the upload itself is the
# publish, and it must wait behind the same gates that hold back the macOS
# artifacts. The .ipa is an artifact leaf like build-macos — its failure
# never blocks the backend, and a re-run/dispatch rebuilds it safely
# (publish-ios tolerates Apple's duplicate rejection).
#
# Signing is Xcode automatic/cloud signing: the ASC API key + team id from
# the shared lux/apple-signing secret let xcodebuild mint the App Store
# distribution cert + profile on the fly — no .p12 and no provisioning
# profile enter the runner. CFBundleShortVersionString/CFBundleVersion are
# restamped from tauri.conf.json's version (release-please bumps it), so
# every release is a unique TestFlight build string with no injection step.
build-ios:
needs: [release, ci]
if: ${{ always() && (github.event_name == 'workflow_dispatch' || (needs.release.outputs.release_created == 'true' && needs.ci.result == 'success')) }}
runs-on: macos-latest
timeout-minutes: 45
permissions:
contents: read
id-token: write # AWS OIDC, to read the Apple signing secret
env:
TAG: ${{ github.event.inputs.tag || needs.release.outputs.tag_name }}
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
with:
ref: ${{ env.TAG }}
- name: Select the newest Xcode 26 (App Store Connect requires the iOS 26 SDK)
# macos-latest still DEFAULTS to Xcode 16.4 / iOS 18.5 SDK, which ASC
# rejects at upload (altool 409: "must be built with the iOS 26 SDK or
# later"). The image ships several 26.x versions; pick the newest
# present so image updates keep working without edits here.
run: |
set -euo pipefail
xcode=$(find /Applications -maxdepth 1 -name 'Xcode_26*.app' | sort -V | tail -n1)
sudo xcode-select -s "$xcode"
echo "selected $xcode"
xcodebuild -version
- uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2
with:
bun-version-file: apps/desktop/package.json
- uses: dtolnay/rust-toolchain@4be7066ada62dd38de10e7b70166bc74ed198c30 # stable branch
with:
targets: aarch64-apple-ios
# rust-cache only, no sccache: the Rust compile runs inside Xcode's
# "Build Rust Code" script phase, where the GHA cache credentials
# sccache's backend needs are one env-propagation hop too fragile.
- uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 # v2.9.1
with:
workspaces: "."
- run: bun install --frozen-lockfile
working-directory: apps/desktop
- uses: aws-actions/configure-aws-credentials@517a711dbcd0e402f90c77e7e2f81e849156e31d # v6
with:
role-to-assume: arn:aws:iam::735853783919:role/lux-release-signing
aws-region: us-west-1
- name: Load Apple signing secret (ASC API key for cloud signing)
# Same lux/apple-signing JSON build-macos uses, but iOS App Store
# signing needs only the ASC API key + team id. `tauri ios build`
# reads the team from APPLE_DEVELOPMENT_TEAM (NOT APPLE_TEAM_ID, which
# is tauri-action's macOS-notarization var); the API key rides in as
# APPLE_API_KEY/APPLE_API_KEY_ID + APPLE_API_ISSUER + APPLE_API_KEY_PATH.
run: |
s=$(aws secretsmanager get-secret-value --secret-id lux/apple-signing --query SecretString --output text)
echo "::add-mask::$(printf '%s' "$s" | jq -r '.api_key_id')"
echo "::add-mask::$(printf '%s' "$s" | jq -r '.api_issuer_id')"
{
echo "APPLE_DEVELOPMENT_TEAM=$(printf '%s' "$s" | jq -r '.team_id')"
echo "APPLE_API_ISSUER=$(printf '%s' "$s" | jq -r '.api_issuer_id')"
echo "APPLE_API_KEY=$(printf '%s' "$s" | jq -r '.api_key_id')"
echo "APPLE_API_KEY_ID=$(printf '%s' "$s" | jq -r '.api_key_id')"
} >> "$GITHUB_ENV"
printf '%s' "$s" | jq -r '.api_key' > "$RUNNER_TEMP/asc_api_key.p8"
echo "APPLE_API_KEY_PATH=$RUNNER_TEMP/asc_api_key.p8" >> "$GITHUB_ENV"
- name: Build + export the App Store .ipa
working-directory: apps/desktop
run: |
set -euo pipefail
bun run tauri ios build --export-method app-store-connect
ipa=$(find src-tauri/gen/apple/build -name '*.ipa' | head -1)
test -n "$ipa" || { echo "::error::tauri ios build produced no .ipa"; exit 1; }
# Prove which identity actually signed the shipped app: tauri's
# interim codesign uses a placeholder ("Apple Distribution: Tauri
# (unset)") that the App Store export re-signs with the
# cloud-managed distribution identity.
unzip -qo "$ipa" -d "$RUNNER_TEMP/ipa-inspect"
codesign -dvv "$RUNNER_TEMP/ipa-inspect/Payload/"*.app 2>&1 | grep -E 'Authority|TeamIdentifier' \
|| echo "::warning::could not read the ipa's code signature"
cp "$ipa" "$RUNNER_TEMP/lux.ipa"
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: ios-ipa
path: ${{ runner.temp }}/lux.ipa
retention-days: 1
# The .ipa already exists (build-ios staged it) — "publishing" is the altool
# push to App Store Connect. Gated on publish-macos succeeding, which already
# sits behind deploy-lambdas + its smokes: TestFlight has no draft state, so
# this sequencing is what keeps a release whose backend or desktop didn't
# ship away from testers. On a workflow_dispatch rebuild the same order
# holds, and an already-uploaded build lands in the duplicate-tolerant path
# below.
publish-ios:
needs: [release, build-ios, publish-macos]
if: ${{ always() && needs.build-ios.result == 'success' && needs.publish-macos.result == 'success' }}
runs-on: macos-latest # xcrun altool is Apple's uploader — macOS only (the default Xcode is fine for uploads)
timeout-minutes: 30
permissions:
contents: read
id-token: write # AWS OIDC, to read the ASC API key
deployments: write # record the TestFlight publish (API, not environment: — see the record step)
env:
TAG: ${{ github.event.inputs.tag || needs.release.outputs.tag_name }}
steps:
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: ios-ipa
path: ${{ runner.temp }}
- uses: aws-actions/configure-aws-credentials@517a711dbcd0e402f90c77e7e2f81e849156e31d # v6
with:
role-to-assume: arn:aws:iam::735853783919:role/lux-release-signing
aws-region: us-west-1
- name: Load the ASC API key (upload credential)
run: |
s=$(aws secretsmanager get-secret-value --secret-id lux/apple-signing --query SecretString --output text)
echo "::add-mask::$(printf '%s' "$s" | jq -r '.api_key_id')"
echo "::add-mask::$(printf '%s' "$s" | jq -r '.api_issuer_id')"
{
echo "APPLE_API_ISSUER=$(printf '%s' "$s" | jq -r '.api_issuer_id')"
echo "APPLE_API_KEY=$(printf '%s' "$s" | jq -r '.api_key_id')"
} >> "$GITHUB_ENV"
printf '%s' "$s" | jq -r '.api_key' > "$RUNNER_TEMP/asc_api_key.p8"
- name: Upload to TestFlight (duplicate-tolerant for re-runs)
run: |
set -euo pipefail
# altool discovers the key as AuthKey_<keyid>.p8 under ~/.appstoreconnect/private_keys.
mkdir -p "$HOME/.appstoreconnect/private_keys"
cp "$RUNNER_TEMP/asc_api_key.p8" "$HOME/.appstoreconnect/private_keys/AuthKey_${APPLE_API_KEY}.p8"
rc=0
out=$(xcrun altool --upload-app --type ios --file "$RUNNER_TEMP/lux.ipa" \
--apiKey "$APPLE_API_KEY" --apiIssuer "$APPLE_API_ISSUER" 2>&1) || rc=$?
echo "$out"
if [ "$rc" -ne 0 ]; then
# A dispatch re-run of a tag whose build may already be on ASC —
# Apple rejects the duplicate, and that rejection IS the desired
# end state, not a failure.
echo "$out" | grep -qiE 'already been uploaded|already exists|redundant binary|previously uploaded' \
&& { echo "build already on App Store Connect — treating as published"; exit 0; }
exit "$rc"
fi
# Record what is live in the repo's Deployments view. Done via the API,
# NOT a job-level `environment:` key — that key rewrites the OIDC subject
# to repo:…:environment:…, which the role trusts (pinned to the main ref)
# would reject, breaking the AWS credentials above.
- name: Record TestFlight deployment
env:
GH_TOKEN: ${{ github.token }}
TAG: ${{ env.TAG }}
run: |
set -euo pipefail
body=$(jq -nc --arg ref "$GITHUB_SHA" --arg desc "$TAG" \
'{ref:$ref, environment:"testflight", production_environment:true, auto_merge:false, required_contexts:[], description:$desc}')
id=$(gh api -X POST "repos/$GITHUB_REPOSITORY/deployments" --input - <<<"$body" --jq '.id')
gh api -X POST "repos/$GITHUB_REPOSITORY/deployments/$id/statuses" \
-f state=success -f environment=testflight \
-f description="iOS build uploaded to TestFlight for $TAG" >/dev/null
echo "recorded testflight deployment $id ($TAG)"
# Builds the Mac App Store flavor and stages the installer-signed .pkg as a
# workflow artifact — the macOS sibling of build-ios. Unlike the iOS job
# there is no cloud signing here (this build doesn't go through Xcode): the
# Apple Distribution + Mac Installer identities come from the lux/mas-signing
# secret into an ephemeral keychain, tauri signs the sandboxed .app
# (Entitlements.mas.plist via tauri.mas.json; `--features mas` compiles the
# self-updater's registration out — store builds update through the store),
# and productbuild wraps the upload package. MAS packages are deliberately
# NOT notarized — App Store Connect ingestion does its own checks.
#
# Needs terraform-apply (unlike build-ios): this job's secret access is
# provisioned by terraform, so a release that changes the grant must apply it
# before the fetch — v0.15.0's first build-mas raced the apply and lost
# (AccessDenied). Dispatch runs skip terraform-apply and bypass it here, the
# same shape build-macos uses for deploy-lambdas.
build-mas:
needs: [release, ci, terraform-apply]
if: ${{ always() && (github.event_name == 'workflow_dispatch' || (needs.release.outputs.release_created == 'true' && needs.ci.result == 'success' && needs.terraform-apply.result == 'success')) }}
runs-on: macos-latest
timeout-minutes: 60
permissions:
contents: read
id-token: write # AWS OIDC, to read the MAS signing secret
env:
TAG: ${{ github.event.inputs.tag || needs.release.outputs.tag_name }}
RUSTC_WRAPPER: sccache
SCCACHE_GHA_ENABLED: "true"
CARGO_INCREMENTAL: "0"
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
with:
ref: ${{ env.TAG }}
- uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2
with:
bun-version-file: apps/desktop/package.json
- uses: dtolnay/rust-toolchain@4be7066ada62dd38de10e7b70166bc74ed198c30 # stable branch
with:
targets: aarch64-apple-darwin,x86_64-apple-darwin
# Same caching pair as build-macos: this flavor is a plain cargo build
# driven by the tauri CLI (no Xcode in the loop), so sccache applies.
- uses: mozilla-actions/sccache-action@9e7fa8a12102821edf02ca5dbea1acd0f89a2696 # v0.0.10
- uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 # v2.9.1
with:
workspaces: "."
- run: bun install --frozen-lockfile
working-directory: apps/desktop
- uses: aws-actions/configure-aws-credentials@517a711dbcd0e402f90c77e7e2f81e849156e31d # v6
with:
role-to-assume: arn:aws:iam::735853783919:role/lux-release-signing
aws-region: us-west-1
- name: Load MAS signing material into an ephemeral keychain
# lux/mas-signing JSON: {certificate, certificate_password,
# signing_identity, installer_certificate, installer_certificate_password,
# installer_identity, provisioning_profile} — p12s/profile base64.
# Passwords stay inside this step; only the identity names (public — they
# appear verbatim in every signature) go to GITHUB_ENV. The
# set-key-partition-list call is what lets codesign/productbuild use the
# keys without a UI prompt no runner could answer.
run: |
set -euo pipefail
s=$(aws secretsmanager get-secret-value --secret-id lux/mas-signing --query SecretString --output text)
keychain="$RUNNER_TEMP/mas-signing.keychain-db"
keychain_pw=$(openssl rand -base64 24)
security create-keychain -p "$keychain_pw" "$keychain"
security set-keychain-settings -lut 21600 "$keychain"
security unlock-keychain -p "$keychain_pw" "$keychain"
printf '%s' "$s" | jq -r '.certificate' | base64 -d > "$RUNNER_TEMP/mas-dist.p12"
printf '%s' "$s" | jq -r '.installer_certificate' | base64 -d > "$RUNNER_TEMP/mas-installer.p12"
security import "$RUNNER_TEMP/mas-dist.p12" -k "$keychain" \
-P "$(printf '%s' "$s" | jq -r '.certificate_password')" -T /usr/bin/codesign
security import "$RUNNER_TEMP/mas-installer.p12" -k "$keychain" \
-P "$(printf '%s' "$s" | jq -r '.installer_certificate_password')" -T /usr/bin/productbuild
rm -f "$RUNNER_TEMP/mas-dist.p12" "$RUNNER_TEMP/mas-installer.p12"
security set-key-partition-list -S apple-tool:,apple: -s -k "$keychain_pw" "$keychain" >/dev/null
security list-keychains -d user -s "$keychain" login.keychain-db
mkdir -p apps/desktop/src-tauri/mas
printf '%s' "$s" | jq -r '.provisioning_profile' | base64 -d > apps/desktop/src-tauri/mas/embedded.provisionprofile
{
echo "APPLE_SIGNING_IDENTITY=$(printf '%s' "$s" | jq -r '.signing_identity')"
echo "MAS_INSTALLER_IDENTITY=$(printf '%s' "$s" | jq -r '.installer_identity')"
} >> "$GITHUB_ENV"
- name: Build the sandboxed .app and wrap the installer-signed .pkg
# APPLE_SIGNING_IDENTITY (tauri's signer) and MAS_INSTALLER_IDENTITY ride
# in from the load step via GITHUB_ENV.
working-directory: apps/desktop
run: |
set -euo pipefail
bun run tauri build --target universal-apple-darwin --features mas --config src-tauri/tauri.mas.json
app="$GITHUB_WORKSPACE/target/universal-apple-darwin/release/bundle/macos/lux.app"
# The one property that separates this flavor from the .dmg build —
# refuse to stage an unsandboxed package.
codesign -d --entitlements - "$app" 2>/dev/null | grep -q 'app-sandbox' \
|| { echo "::error::built .app is not sandboxed — MAS flavor misconfigured"; exit 1; }
xcrun productbuild --sign "$MAS_INSTALLER_IDENTITY" \
--component "$app" /Applications "$RUNNER_TEMP/lux-mas.pkg"
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: mas-pkg
path: ${{ runner.temp }}/lux-mas.pkg
retention-days: 1
- name: sccache stats
if: ${{ always() }}
run: sccache --show-stats
# The macOS sibling of publish-ios: the .pkg already exists, "publishing" is
# the altool push to App Store Connect, behind the same publish-macos gate
# (and transitively the backend deploys + smokes). Duplicate-tolerant so
# dispatch re-runs of an already-uploaded tag succeed.
publish-mas:
needs: [release, build-mas, publish-macos]
if: ${{ always() && needs.build-mas.result == 'success' && needs.publish-macos.result == 'success' }}
runs-on: macos-latest # xcrun altool is Apple's uploader — macOS only
timeout-minutes: 30
permissions:
contents: read
id-token: write # AWS OIDC, to read the ASC API key
deployments: write # record the TestFlight publish (API, not environment: — see the record step)
env:
TAG: ${{ github.event.inputs.tag || needs.release.outputs.tag_name }}
steps:
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: mas-pkg
path: ${{ runner.temp }}
- uses: aws-actions/configure-aws-credentials@517a711dbcd0e402f90c77e7e2f81e849156e31d # v6
with:
role-to-assume: arn:aws:iam::735853783919:role/lux-release-signing
aws-region: us-west-1
- name: Load the ASC API key (upload credential)
run: |
s=$(aws secretsmanager get-secret-value --secret-id lux/apple-signing --query SecretString --output text)
echo "::add-mask::$(printf '%s' "$s" | jq -r '.api_key_id')"
echo "::add-mask::$(printf '%s' "$s" | jq -r '.api_issuer_id')"
{
echo "APPLE_API_ISSUER=$(printf '%s' "$s" | jq -r '.api_issuer_id')"
echo "APPLE_API_KEY=$(printf '%s' "$s" | jq -r '.api_key_id')"
} >> "$GITHUB_ENV"
printf '%s' "$s" | jq -r '.api_key' > "$RUNNER_TEMP/asc_api_key.p8"
- name: Upload to TestFlight (duplicate-tolerant for re-runs)
run: |
set -euo pipefail
mkdir -p "$HOME/.appstoreconnect/private_keys"
cp "$RUNNER_TEMP/asc_api_key.p8" "$HOME/.appstoreconnect/private_keys/AuthKey_${APPLE_API_KEY}.p8"
rc=0
out=$(xcrun altool --upload-app --type macos --file "$RUNNER_TEMP/lux-mas.pkg" \
--apiKey "$APPLE_API_KEY" --apiIssuer "$APPLE_API_ISSUER" 2>&1) || rc=$?
echo "$out"
if [ "$rc" -ne 0 ]; then
echo "$out" | grep -qiE 'already been uploaded|already exists|redundant binary|previously uploaded' \
&& { echo "build already on App Store Connect — treating as published"; exit 0; }
exit "$rc"
fi
# Record what is live in the repo's Deployments view. Done via the API,
# NOT a job-level `environment:` key — that key rewrites the OIDC subject
# to repo:…:environment:…, which the role trusts (pinned to the main ref)
# would reject, breaking the AWS credentials above.
- name: Record TestFlight (macOS) deployment
env:
GH_TOKEN: ${{ github.token }}
TAG: ${{ env.TAG }}
run: |
set -euo pipefail
body=$(jq -nc --arg ref "$GITHUB_SHA" --arg desc "$TAG" \
'{ref:$ref, environment:"testflight-macos", production_environment:true, auto_merge:false, required_contexts:[], description:$desc}')
id=$(gh api -X POST "repos/$GITHUB_REPOSITORY/deployments" --input - <<<"$body" --jq '.id')
gh api -X POST "repos/$GITHUB_REPOSITORY/deployments/$id/statuses" \
-f state=success -f environment=testflight-macos \
-f description="Mac App Store build uploaded to TestFlight for $TAG" >/dev/null
echo "recorded testflight-macos deployment $id ($TAG)"
# The node binary becomes a public release asset, so installing on a Linux
# box is a curl of releases/latest + `sudo ./lux-node install` — no Actions
# login to fetch an artifact. This job only builds and stages: attaching to
# the release is node-publish's, so the musl builds run in parallel with the
# other build legs instead of trailing build-macos.
node-build:
needs: [release, ci]
if: ${{ needs.release.outputs.release_created == 'true' }}
strategy:
matrix:
include:
# Native runner per target: Ubuntu's musl-tools only ships musl-gcc
# for the host arch. aarch64 is the Raspberry Pi / small-ARM-box story.
- target: x86_64-unknown-linux-musl
runner: ubuntu-latest
asset: lux-node-x86_64-linux
- target: aarch64-unknown-linux-musl
runner: ubuntu-24.04-arm
asset: lux-node-aarch64-linux
runs-on: ${{ matrix.runner }}
timeout-minutes: 30
permissions:
contents: read
env:
TAG: ${{ needs.release.outputs.tag_name }}
RUSTC_WRAPPER: sccache
SCCACHE_GHA_ENABLED: "true"
CARGO_INCREMENTAL: "0"
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
with:
ref: ${{ env.TAG }}
- uses: dtolnay/rust-toolchain@4be7066ada62dd38de10e7b70166bc74ed198c30 # stable branch
with:
targets: ${{ matrix.target }}
- name: Install musl-gcc
run: sudo apt-get update && sudo apt-get install -y musl-tools
- uses: mozilla-actions/sccache-action@9e7fa8a12102821edf02ca5dbea1acd0f89a2696 # v0.0.10
- uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 # v2.9.1
with:
workspaces: "."
cache-targets: "false"
key: ${{ matrix.target }}
- name: Build (static musl)
run: cargo build --release --target ${{ matrix.target }} -p lux-node
- name: Stage the binary
run: cp "target/${{ matrix.target }}/release/lux-node" "${{ matrix.asset }}"
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: ${{ matrix.asset }}
path: ${{ matrix.asset }}
retention-days: 1
# The node sibling of publish-ios/publish-mas: the binaries already exist,
# "publishing" is attaching them as release assets. Behind publish-macos on
# purpose: the assetless-release resolver treats "release has assets" as
# "finished", so nothing may attach assets before the macOS publish does.
node-publish:
needs: [release, node-build, publish-macos]
if: ${{ needs.release.outputs.release_created == 'true' }}
runs-on: ubuntu-latest
timeout-minutes: 10
permissions:
contents: write # upload the release assets
env:
TAG: ${{ needs.release.outputs.tag_name }}
steps:
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
pattern: lux-node-*
merge-multiple: true
- name: Upload as release assets
env:
GH_TOKEN: ${{ github.token }}
run: |
gh release upload "$TAG" lux-node-x86_64-linux lux-node-aarch64-linux \
--clobber -R "$GITHUB_REPOSITORY"
echo "uploaded node binaries to $TAG"