fix(ios): raise minimum iOS version to 15.0 (#266) #245
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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@3d3c42e5aac5ba805825da76410c181273ba90b1 # 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@3d3c42e5aac5ba805825da76410c181273ba90b1 # 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@e6de054238d6b7531b4efff3b6587d9aade6a06c # 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@3d3c42e5aac5ba805825da76410c181273ba90b1 # 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@fc920bf0ec8de6ee65d409111f7ec508035751ba # v0.0.11 | |
| # 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@e6de054238d6b7531b4efff3b6587d9aade6a06c # 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@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 | |
| with: | |
| ref: ${{ needs.release.outputs.tag_name }} | |
| - uses: aws-actions/configure-aws-credentials@e6de054238d6b7531b4efff3b6587d9aade6a06c # 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@3d3c42e5aac5ba805825da76410c181273ba90b1 # 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@fc920bf0ec8de6ee65d409111f7ec508035751ba # v0.0.11 | |
| - 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@e6de054238d6b7531b4efff3b6587d9aade6a06c # 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@3d3c42e5aac5ba805825da76410c181273ba90b1 # 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@e6de054238d6b7531b4efff3b6587d9aade6a06c # 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@e6de054238d6b7531b4efff3b6587d9aade6a06c # 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@3d3c42e5aac5ba805825da76410c181273ba90b1 # 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@fc920bf0ec8de6ee65d409111f7ec508035751ba # v0.0.11 | |
| - 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@e6de054238d6b7531b4efff3b6587d9aade6a06c # 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@e6de054238d6b7531b4efff3b6587d9aade6a06c # 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@3d3c42e5aac5ba805825da76410c181273ba90b1 # 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@fc920bf0ec8de6ee65d409111f7ec508035751ba # v0.0.11 | |
| - 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" |