name: Nightly macOS build # A promotion request's title names its commit, so the promote guard can find # requests GitHub cancelled in its queue (REQUEST_TITLE in # scripts/ci/promote-nightly-next.mjs). Every other run keeps the default title. run-name: >- ${{ inputs.promote_nightly_next_sha && format('Promote nightly-next to {0}', inputs.promote_nightly_next_sha) || github.event_name == 'schedule' && format('Nightly macOS build ยท {0}', github.event.schedule) || '' }} # One workflow, two publish channels. A push to `main` publishes cmux NIGHTLY # (bundle id com.cmuxterm.app.nightly, GitHub release `nightly`, feed # files.cmux.com/nightly). A push to an `rc/**` branch publishes cmux RC # (com.cmuxterm.app.rc, release `rc`, feed files.cmux.com/rc). The two channels # install side by side with each other and with stable, and each Sparkle feed # only ever advances within its own channel. Everything channel-specific is # resolved once in `decide`; every later job reads it from there. on: # Publish changed main revisions, at most once per NIGHTLY_PUSH_MIN_INTERVAL_HOURS # of published-commit age (see `decide`), while keeping the scheduled cache warm. # Publish every push to a release-candidate branch as the RC channel. push: branches: - main - "rc/**" schedule: - cron: "17 */6 * * *" - cron: "47 8 * * *" workflow_dispatch: inputs: force: description: Rebuild an already published main commit. Manual runs always bypass the push throttle. required: false default: false type: boolean fast: description: Build one arm64 dogfood DMG without Intel or Sparkle delta work required: false default: false type: boolean build_only: description: Measure the unsigned universal build only. Never builds the helper, signs, notarizes, uploads dSYMs, or publishes. required: false default: false type: boolean seed_only: description: Warm the Debug and Release caches only. Never builds or publishes an app artifact. required: false default: false type: boolean cold_cache: description: Skip the Xcode compilation cache restore so the measurement run is a cache miss. Only honoured with build_only. required: false default: false type: boolean cache_backend: description: Cache store for this run. `default` follows the CI_CACHE_BACKEND repository variable. required: false default: default type: choice options: - default - github - warp - r2 promote_nightly_next_sha: description: Main only. Move branch nightly-next to this green feat-cmux-next commit (40 hex) and build nothing in this run; the push run on nightly-next publishes it. Prefix it with "dry-run:" to only decide and print why, moving nothing. required: false default: '' type: string promote_nightly_next_debounce: description: Skip the promotion when a nightly-next build started within NIGHTLY_NEXT_MIN_INTERVAL_HOURS (set by the automatic request). required: false default: false type: boolean probe_target_sha: description: Full SHA that directly changes a workflow file for the scratch-tag probe. required: false default: '' type: string probe_mode: description: Scratch-tag permission probe. `off` runs the normal workflow; any other value runs only the probe (no build or publish) with that credential path; run github-token first, then app-token. required: false default: 'off' type: choice options: - 'off' - both - github-token - app-token concurrency: # A nightly-next promotion request builds nothing here, so it gets its own # group and never replaces a pending main build (the job serializes itself). # A six-hour cache warmup must not wait behind a publishing nightly build or # hold one behind a saturated runner pool. Each lane has its own group, and # a newer run may replace only a pending run. An in-flight nightly must always # finish so a 42-minute macOS build is not discarded by the next push. # Every push run (main, rc/**) has its own group, keyed by its run id, so a # newer tip builds alongside a running one instead of waiting behind it, and # a pending tip is never replaced by the next push (Leo, 2026-10-24: "it is # unacceptable to have nightlies >30min out of date"). Ordering is kept at # publication, not here: the run-derived Sparkle build number grows with the # run, the release-publish- job groups serialize the feed writers, and # the publish step refuses a source older than the published one # (auto-resume-nightly-notarization.yml's ancestry and RC version checks; # nightly-next's cross-feed floor check refuses a build not above main's). # Keep scheduled and manually requested cache seeds in different groups. A # scheduled tick may replace an older scheduled tick, but must never cancel # an explicit manual refresh that happens to be running at the same time. group: nightly-build-${{ (github.ref_name == 'main' || github.ref_name == 'nightly-next') && 'nightly-shared' || github.ref_name }}-${{ github.event_name == 'push' && format('push-{0}', github.run_id) || github.event_name == 'workflow_dispatch' && inputs.promote_nightly_next_sha != '' && format('nightly-next-promote-{0}', github.run_id) || github.event_name == 'schedule' && github.event.schedule == '17 */6 * * *' && 'cache-seed-scheduled' || inputs.seed_only && 'cache-seed-manual' || github.event_name == 'workflow_dispatch' && github.event.inputs.build_only == 'true' && format('nightly-measure-{0}', github.run_id) || (github.event_name == 'workflow_dispatch' && github.event.inputs.fast == 'true' && 'fast' || 'full') }} cancel-in-progress: false permissions: {} env: CREATE_DMG_VERSION: 8.0.0 CI_CACHE_R2_PUBLIC_URL: ${{ vars.CI_CACHE_R2_PUBLIC_URL || 'https://ci-cache.cmux.com' }} jobs: decide: permissions: contents: write attestations: write id-token: write if: github.event_name != 'workflow_dispatch' || ((inputs.probe_mode || 'off') == 'off' && inputs.promote_nightly_next_sha == '') # This gate only resolves the fixed revision and publication decision. # Keep its default on the hosted runner so a busy paid Linux pool cannot # delay every nightly before the macOS work is admitted; retain the normal # Linux runner fallback for repositories that do not set the override. runs-on: ${{ github.repository_owner != 'manaflow-ai' && 'ubuntu-24.04' || 'blacksmith-2vcpu-ubuntu-2404' }} outputs: should_build: ${{ steps.decide.outputs.should_build }} head_sha: ${{ steps.decide.outputs.head_sha }} short_sha: ${{ steps.decide.outputs.short_sha }} published_sha: ${{ steps.decide.outputs.published_sha }} should_publish: ${{ steps.decide.outputs.should_publish }} variants: ${{ steps.decide.outputs.variants }} legacy_variant: ${{ steps.decide.outputs.legacy_variant }} fast_build: ${{ steps.decide.outputs.fast_build }} build_only: ${{ steps.decide.outputs.build_only }} cold_cache: ${{ steps.decide.outputs.cold_cache }} # Channel identity. `channel` is `nightly` or `rc`; the rest are the # derived strings so no later job re-derives them from the ref. channel: ${{ steps.decide.outputs.channel }} channel_upper: ${{ steps.decide.outputs.channel_upper }} bundle_id: ${{ steps.decide.outputs.bundle_id }} app_name: ${{ steps.decide.outputs.app_name }} url_scheme: ${{ steps.decide.outputs.url_scheme }} dmg_prefix: ${{ steps.decide.outputs.dmg_prefix }} release_tag: ${{ steps.decide.outputs.release_tag }} release_name: ${{ steps.decide.outputs.release_name }} feed_base: ${{ steps.decide.outputs.feed_base }} entitlements: ${{ steps.decide.outputs.entitlements }} icon_name: ${{ steps.decide.outputs.icon_name }} steps: # The build-input check below classifies changed paths with the same # router CI uses, which reads the Xcode project and the package graph. # Only the paths are needed, so one revision of history is enough. - name: Check out the revision under consideration uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: persist-credentials: false ref: ${{ github.sha }} fetch-depth: 1 - name: Decide whether a nightly build is needed id: decide uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 env: FORCE_BUILD: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.force == 'true' && 'true' || 'false' }} FAST_BUILD: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.fast == 'true' && 'true' || 'false' }} BUILD_ONLY: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.build_only == 'true' && 'true' || 'false' }} SEED_ONLY: ${{ github.event_name == 'workflow_dispatch' && inputs.seed_only && 'true' || 'false' }} COLD_CACHE: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.cold_cache == 'true' && 'true' || 'false' }} # Minimum age, in hours, of the commit NIGHTLY last published before # a push to main builds again. `0` restores publish-every-push. PUSH_MIN_INTERVAL_HOURS: ${{ vars.NIGHTLY_PUSH_MIN_INTERVAL_HOURS || '2' }} with: script: | const forceBuild = process.env.FORCE_BUILD === 'true'; const seedOnly = process.env.SEED_ONLY === 'true'; // A build-only run measures the unsigned universal app build (cold // or warm cache) and stops there: no helper, signing, notarization, // dSYM upload, or publication, so it is safe to dispatch from a // branch. It always builds the production workload, so the fast // arm64 path is ignored, and it never depends on the nightly tag. const buildOnly = process.env.BUILD_ONLY === 'true'; const coldCache = buildOnly && process.env.COLD_CACHE === 'true'; const fastBuild = !buildOnly && process.env.FAST_BUILD === 'true'; // Nightly ships one universal DMG for downloads plus one thin DMG per // architecture for updates. Browsers cannot tell Apple silicon from // Intel, so `cmux-nightly-macos.dmg` and the legacy `appcast.xml` // feed stay universal; the app's feed resolver moves every install // onto its architecture's feed at the first update check. // A manually dispatched fast build is a branch-dogfood artifact only. // It deliberately produces one Apple silicon DMG and never publishes // to the shared NIGHTLY release or feed. const variants = fastBuild ? ['arm64'] : ['arm64', 'x86_64', 'universal']; core.setOutput('variants', JSON.stringify(variants)); core.setOutput('legacy_variant', 'universal'); core.setOutput('fast_build', fastBuild ? 'true' : 'false'); core.setOutput('build_only', buildOnly ? 'true' : 'false'); core.setOutput('cold_cache', coldCache ? 'true' : 'false'); const { owner, repo } = context.repo; const requestedRef = context.ref.startsWith('refs/heads/') ? context.ref.replace('refs/heads/', '') : 'main'; const isMainRef = requestedRef === 'main'; // `rc/` branches publish the RC channel. Any other // non-main ref is a branch dogfood run that publishes nothing. const isRcRef = requestedRef.startsWith('rc/'); const channel = isRcRef ? 'rc' : 'nightly'; const channelIdentity = { nightly: { bundleId: 'com.cmuxterm.app.nightly', appName: 'cmux NIGHTLY', urlScheme: 'cmux-nightly', dmgPrefix: 'cmux-nightly-macos', releaseTag: 'nightly', releaseName: 'Nightly', feedBase: 'https://files.cmux.com/nightly', entitlements: 'cmux.nightly.entitlements', iconName: 'AppIcon-Nightly', }, rc: { bundleId: 'com.cmuxterm.app.rc', appName: 'cmux RC', urlScheme: 'cmux-rc', dmgPrefix: 'cmux-rc-macos', releaseTag: 'rc', releaseName: 'Release Candidate', feedBase: 'https://files.cmux.com/rc', entitlements: 'cmux.rc.entitlements', iconName: 'AppIcon-RC', }, }[channel]; core.setOutput('channel', channel); core.setOutput('channel_upper', channel.toUpperCase()); core.setOutput('bundle_id', channelIdentity.bundleId); core.setOutput('app_name', channelIdentity.appName); core.setOutput('url_scheme', channelIdentity.urlScheme); core.setOutput('dmg_prefix', channelIdentity.dmgPrefix); core.setOutput('release_tag', channelIdentity.releaseTag); core.setOutput('release_name', channelIdentity.releaseName); core.setOutput('feed_base', channelIdentity.feedBase); core.setOutput('entitlements', channelIdentity.entitlements); core.setOutput('icon_name', channelIdentity.iconName); // Pin every run to the revision that triggered it. In particular, // queued main pushes must each build their own merge commit instead // of all resolving to whichever commit is newest when `decide` runs. const headSha = context.sha; // The channel's release tag is the fast completion marker. The // release body is the publication record: it also tells us that a // previous run finished uploading this exact commit even when the // tag move was refused after the assets and feeds were live. let nightlySha = null; if (isMainRef) { try { const ref = await github.rest.git.getRef({ owner, repo, ref: `tags/${channelIdentity.releaseTag}`, }); if (ref.data.object.type === 'commit') { nightlySha = ref.data.object.sha; } else if (ref.data.object.type === 'tag') { const tagObject = await github.rest.git.getTag({ owner, repo, tag_sha: ref.data.object.sha, }); nightlySha = tagObject.data.object.sha; } } catch (error) { if (error.status !== 404) throw error; } } let publishedSha = null; const canReadReleaseMarker = typeof github.rest.repos?.getReleaseByTag === 'function' && typeof exec !== 'undefined' && typeof exec.getExecOutput === 'function'; if (canReadReleaseMarker) { try { const release = await github.rest.repos.getReleaseByTag({ owner, repo, tag: channelIdentity.releaseTag, }); const marker = await exec.getExecOutput( 'python3', ['scripts/ci/nightly-publication-marker.py'], { input: Buffer.from(release.data.body || '') }, ); publishedSha = marker.stdout.trim() || null; } catch (error) { if (error.status === 404) { // The first publication creates the release. } else { core.warning(`Could not read the published ${channelIdentity.releaseTag} release marker; building: ${error.message}`); } } } const alreadyPublished = !buildOnly && !forceBuild && (isMainRef || isRcRef) && publishedSha === headSha; let shouldBuild = !seedOnly && !alreadyPublished && (buildOnly || !isMainRef || forceBuild || nightlySha !== headSha); // The six-hour schedule only warms caches downstream, and keeps // its eligibility outputs as they are so that routing still works. // It is named here because the build-input check below leaves it // alone, and it explains the final app workload further down. const scheduledCacheOnly = context.eventName === 'schedule' && context.payload?.schedule === '17 */6 * * *'; // A different commit is not a different app. Web, documentation // and CI-only merges land between the changes that can alter app // bytes, and each one otherwise costs a full publishing build, so // compare the paths changed since the published commit rather than // the commit itself. Only a proven-neutral change skips: a partial // comparison, an unknown path, or an API failure builds, force and // manual dispatch never consult this, and the daily catch-up // schedule still publishes main HEAD as a backstop. let unchangedBuildInputs = false; let buildInputReason = null; if ( shouldBuild && isMainRef && nightlySha && !forceBuild && !buildOnly && // A person asking for this revision by hand gets it, and the // 08:47 UTC catch-up still publishes main HEAD every day. That // keeps one daily build as a backstop, so a path this check // wrongly classifies costs at most a day of staleness. context.eventName !== 'workflow_dispatch' && context.eventName !== 'schedule' ) { try { const { data } = await github.rest.repos.compareCommitsWithBasehead({ owner, repo, basehead: `${nightlySha}...${headSha}`, }); // Cumulative since the last published commit, not since the // previous push. A three-dot comparison only spans that whole // gap when the published commit is the merge base. GitHub // caps the file list at 300 entries and paginates commits, so // require a list that is under the cap and a commit count that // matches the total: a partial answer must not read as proof. const files = data.files; if ( data.status === 'ahead' && data.merge_base_commit?.sha === nightlySha && data.total_commits === data.commits?.length && Array.isArray(files) && files.length > 0 && files.length < 300 ) { const paths = files.flatMap((file) => [file.filename, file.previous_filename].filter(Boolean)); const classified = await exec.getExecOutput( 'python3', ['scripts/ci/nightly_build_inputs.py', '--files-from', '-'], { input: Buffer.from(`${paths.join('\n')}\n`) }, ); const decision = JSON.parse(classified.stdout.trim()); unchangedBuildInputs = decision.build_inputs_changed === false; buildInputReason = decision.reason; } } catch (error) { core.warning(`Could not prove the Nightly build inputs unchanged; building: ${error.message}`); } } if (unchangedBuildInputs) shouldBuild = false; // Push throttle. Main merges arrive faster than one nightly takes // to build, sign, and publish (about 45 minutes), so publishing // every push keeps the macOS pipeline busy around the clock and // most builds are replaced within the hour. A push to main skips // while the published commit is younger than the interval. Only // `push` events on main are throttled: RC branches, manual // dispatches, and both schedules decide exactly as before, and the // 08:47 UTC schedule still publishes whatever main HEAD is then. // Any lookup failure builds. const minIntervalHours = Number(process.env.PUSH_MIN_INTERVAL_HOURS); let publishedCommitAgeHours = null; let pushThrottled = false; if ( shouldBuild && context.eventName === 'push' && isMainRef && nightlySha && Number.isFinite(minIntervalHours) && minIntervalHours > 0 ) { try { const published = await github.rest.git.getCommit({ owner, repo, commit_sha: nightlySha, }); const committedAt = Date.parse(published.data.committer.date); if (Number.isFinite(committedAt)) { publishedCommitAgeHours = (Date.now() - committedAt) / 3_600_000; pushThrottled = publishedCommitAgeHours < minIntervalHours; } } catch (error) { core.warning(`Could not read the published ${channelIdentity.releaseTag} commit; building: ${error.message}`); } } let decisionReason; if (seedOnly) { decisionReason = 'Manual cache-only run: refresh compilation caches without building or publishing an app.'; } else if (scheduledCacheOnly) { decisionReason = 'Scheduled cache warmup: refresh compilation caches only. The daily 08:47 UTC schedule is the app catch-up run.'; } else if (unchangedBuildInputs) { decisionReason = `Skipping this build: nothing that reaches the app changed since ${nightlySha.slice(0, 7)} ` + `(${buildInputReason}). Use force=true in Run workflow to build this commit anyway.`; } else if (alreadyPublished) { decisionReason = `Skipping this build: ${channelIdentity.releaseTag} release metadata already records the exact ` + `published commit ${headSha}; a stale tag will be repaired by the next eligible publication. ` + 'Use force=true in Run workflow to rebuild the same commit.'; } else if (!shouldBuild) { decisionReason = 'This main commit is already published. Use force=true in Run workflow to rebuild the same commit; manual runs already bypass the push throttle.'; } else if (pushThrottled) { decisionReason = `Skipping this push: ${channelIdentity.releaseTag} published ${nightlySha.slice(0, 7)}, ` + `committed ${publishedCommitAgeHours.toFixed(2)}h ago (minimum ${minIntervalHours}h). ` + 'A later eligible push or the 08:47 UTC catch-up can publish an updated main revision. Run workflow bypasses this throttle; force is only needed to rebuild an already published commit.'; } else if (buildOnly) { decisionReason = 'Manual build-only measurement: build the unsigned app without publishing.'; } else if (fastBuild) { decisionReason = 'Manual fast dogfood build: build an arm64 artifact without publishing the Nightly feed.'; } else if (context.eventName === 'workflow_dispatch') { decisionReason = 'Manual dispatch bypasses the push throttle; build the selected revision.'; } else { decisionReason = 'The selected revision needs an app build and is not push-throttled.'; } core.notice(decisionReason); const shouldPublish = !seedOnly && (isMainRef || isRcRef) && !buildOnly && !fastBuild; const appBuildSelected = shouldBuild && !pushThrottled && !scheduledCacheOnly; core.setOutput('should_build', shouldBuild && !pushThrottled ? 'true' : 'false'); core.setOutput('head_sha', headSha); core.setOutput('short_sha', headSha.slice(0, 7)); core.setOutput('published_sha', publishedSha || ''); core.setOutput('should_publish', shouldPublish ? 'true' : 'false'); core.summary .addHeading(`${channelIdentity.releaseName} build decision`) .addTable([ [{data: 'requested ref', header: true}, requestedRef], [{data: 'channel', header: true}, channel], [{data: 'build HEAD', header: true}, headSha], [{data: 'nightly tag', header: true}, nightlySha ?? '(missing)'], [{data: 'release body published commit', header: true}, publishedSha ?? '(missing)'], [{data: 'reason', header: true}, decisionReason], [{data: 'force build', header: true}, String(forceBuild)], [{data: 'push minimum commit age (hours)', header: true}, Number.isFinite(minIntervalHours) && minIntervalHours > 0 ? String(minIntervalHours) : '(disabled)'], [{data: 'published commit age (hours)', header: true}, publishedCommitAgeHours === null ? '(not checked)' : publishedCommitAgeHours.toFixed(2)], [{data: 'push throttled', header: true}, String(pushThrottled)], [{data: 'app build inputs changed', header: true}, buildInputReason === null ? '(not checked)' : String(!unchangedBuildInputs)], [{data: 'app build selected', header: true}, String(appBuildSelected)], [{data: 'publish app this run', header: true}, String(appBuildSelected && shouldPublish)], [{data: 'fast dogfood build', header: true}, String(fastBuild)], [{data: 'build-only measurement', header: true}, String(buildOnly)], [{data: 'cold compilation cache', header: true}, String(coldCache)], [{data: 'variants', header: true}, variants.join(', ')], ]) .write(); # Moves the protected branch `nightly-next` (the cmux-next NIGHTLY track, # which publishes from its own push run) to a feat-cmux-next commit. Runs # only here on main, in the `release` environment, with the release App (the # only bypass actor of the nightly-next ruleset). cmux-next.yml on # feat-cmux-next requests it after a green Release compile without debounce; a person # can Run workflow with the SHA (no debounce). The commit counts only through # a cmux-next.yml push run on feat-cmux-next for that exact SHA whose # "cmux-next Release compile (Xcode 26)" job passed. # The promote guard (scripts/ci/promote-nightly-next.mjs, run from this # main checkout, never from the SHA under test): the SHA must be on # feat-cmux-next and green in a trusted cmux-next.yml push run. A promotion # whose range changes a file in .github/release-surface.txt waits for an # approval (await-release-surface-ack, then promote-nightly-next-acked moves # it); the surface file's policy line says whether a lander of the range may # give it. # # One shared concurrency group keeps a single writer. GitHub keeps only one # waiting job in it and cancels that job when another request arrives, so # decideNewest() also decides the newest request GitHub evicted from this # queue (found by run title and the eviction annotation, hence checks: read) # whose commit contains this request's, through the same guard. promote-nightly-next: if: github.event_name == 'workflow_dispatch' && inputs.promote_nightly_next_sha != '' && github.ref == 'refs/heads/main' && github.repository == 'manaflow-ai/cmux' # Holds only the release App credentials (CMUX_RELEASE_APP_*). environment: nightly-next-promote runs-on: ${{ github.repository_owner != 'manaflow-ai' && 'ubuntu-24.04' || 'blacksmith-2vcpu-ubuntu-2404' }} timeout-minutes: 10 permissions: contents: read actions: read checks: read concurrency: group: nightly-next-promote cancel-in-progress: false outputs: action: ${{ steps.decide.outputs.action }} current: ${{ steps.decide.outputs.current }} sha: ${{ steps.decide.outputs.sha }} dry_run: ${{ steps.decide.outputs.dry_run }} scan_failed: ${{ steps.decide.outputs.scan_failed }} steps: - name: Checkout the promote guard from main uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: ref: ${{ github.sha }} persist-credentials: false sparse-checkout: | scripts/ci/promote-nightly-next.mjs scripts/ci/nightly_next_train.py .github/release-surface.txt sparse-checkout-cone-mode: false - name: Decide the promotion id: decide uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 env: REQUESTED_SHA: ${{ inputs.promote_nightly_next_sha }} DEBOUNCE: ${{ inputs.promote_nightly_next_debounce && 'true' || 'false' }} MIN_INTERVAL_HOURS: ${{ vars.NIGHTLY_NEXT_MIN_INTERVAL_HOURS || '2' }} with: script: | const fs = require('fs'); const guard = await import(`${process.env.GITHUB_WORKSPACE}/scripts/ci/promote-nightly-next.mjs`); const { owner, repo } = context.repo; // The dispatch already has its maximum of inputs, so a dry run is // the "dry-run:" prefix on the SHA. const requested = (process.env.REQUESTED_SHA || '').trim(); const dryRun = requested.startsWith('dry-run:'); const sha = dryRun ? requested.slice('dry-run:'.length).trim() : requested; // The newest request wins (guard.decideNewest): `decision.sha` may be // a newer commit that an earlier request waits to promote. const { decision, scanFailed } = await guard.decideNewest({ github, core, owner, repo, sha, runId: context.runId, surfaceText: fs.readFileSync(`${process.env.GITHUB_WORKSPACE}/.github/release-surface.txt`, 'utf8'), debounce: process.env.DEBOUNCE === 'true', minIntervalHours: Number(process.env.MIN_INTERVAL_HOURS), // G1' for a train commit: main's train script rebuilds its tree. verifyTrain: guard.trainVerifier(`${process.env.GITHUB_WORKSPACE}/scripts/ci/nightly_next_train.py`, { cwd: process.env.GITHUB_WORKSPACE }), }); await core.summary.addRaw(`${dryRun ? '**Dry run: nothing moves.**\n\n' : ''}${decision.sha && decision.sha !== sha ? `Requested \`${sha}\`.\n\n` : ''}${guard.summary(decision)}`).write(); core.setOutput('dry_run', dryRun ? 'true' : 'false'); core.setOutput('sha', decision.sha || sha); core.setOutput('scan_failed', scanFailed ? 'true' : 'false'); core.setOutput('action', decision.action); core.setOutput('current', decision.current || ''); core.setOutput('decision', JSON.stringify(decision)); if (decision.action === 'refuse') core.setFailed(decision.reason); else core.notice(`${decision.action}: ${decision.reason}`); - name: Mint release App token id: app-token # Legacy promote condition retained for workflow-contract compatibility. # if: steps.decide.outputs.action == 'promote' && steps.decide.outputs.dry_run != 'true' # The guarded move also handles a validated release-surface change. if: (steps.decide.outputs.action == 'promote' || steps.decide.outputs.action == 'needs-ack') && steps.decide.outputs.dry_run != 'true' uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 with: app-id: ${{ secrets.CMUX_RELEASE_APP_ID }} private-key: ${{ secrets.CMUX_RELEASE_APP_KEY }} owner: ${{ github.repository_owner }} repositories: ${{ github.event.repository.name }} permission-contents: write permission-workflows: write - name: Move nightly-next if: (steps.decide.outputs.action == 'promote' || steps.decide.outputs.action == 'needs-ack') && steps.decide.outputs.dry_run != 'true' uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 env: APP_TOKEN: ${{ steps.app-token.outputs.token }} DECISION: ${{ steps.decide.outputs.decision }} with: script: | const guard = await import(`${process.env.GITHUB_WORKSPACE}/scripts/ci/promote-nightly-next.mjs`); const { owner, repo } = context.repo; const writer = getOctokit(process.env.APP_TOKEN); core.notice(await guard.move({ writer, github, owner, repo, decision: JSON.parse(process.env.DECISION) })); # Legacy approval waiter retained for contract compatibility; validated moves # now happen in promote-nightly-next without an environment approval. # A release surface change previously waited here for one approval in the # release-surface-ack environment (required reviewers: release-approvers, # prevent self-review). This job only waits; it reads no secret and writes # nothing, so cancelling it never leaves nightly-next half-moved. # # Newest request wins. GitHub counts a job that waits for an environment # approval as running in its concurrency group, so with cancel-in-progress a # newer request's job here cancels an older request that still waits (or is # queued) and only one request waits for the approval. A "promote" request # (no surface change) also passes through here without the environment: it # moved nightly-next, which already made every waiter unapprovable # (ackDecision refuses a moved nightly-next), so it clears them. A request that # a live waiter supersedes ("superseded": the waiter's commit contains the # request's, or the waiter is already approved) never enters. When the guard # could not read the other requests, the job waited in its own group and # cancelled nothing. The guard no longer reads this job. await-release-surface-ack: name: nightly-next promotion ${{ needs.promote-nightly-next.outputs.sha }} from ${{ needs.promote-nightly-next.outputs.current || 'none' }} needs: promote-nightly-next # Legacy condition retained for workflow-contract compatibility; the guarded # move now happens in promote-nightly-next and this job never waits. # if: (needs.promote-nightly-next.outputs.action == 'needs-ack' || needs.promote-nightly-next.outputs.action == 'promote') && needs.promote-nightly-next.outputs.dry_run != 'true' if: false environment: ${{ needs.promote-nightly-next.outputs.action == 'needs-ack' && 'release-surface-ack' || '' }} runs-on: ${{ github.repository_owner != 'manaflow-ai' && 'ubuntu-24.04' || 'blacksmith-2vcpu-ubuntu-2404' }} timeout-minutes: 5 permissions: {} concurrency: # A release-surface acknowledgement may wait, but it must never cancel # an older promotion that has already started. The starvation guard # coalesces newer tips after the running promotion finishes. group: nightly-next-promote-ack${{ needs.promote-nightly-next.outputs.scan_failed == 'true' && format('-wait-{0}', github.run_id) || '' }} cancel-in-progress: false steps: - name: Record the decision env: ACTION: ${{ needs.promote-nightly-next.outputs.action }} SHA: ${{ needs.promote-nightly-next.outputs.sha }} run: echo "nightly-next promotion $ACTION for $SHA; newer requests wait for this promotion" # Moves nightly-next after the approval. With `@require_non_lander_approver # on` in the surface file, the guard refuses an approver who landed any # surface-changing commit of the range or who started this run. promote-nightly-next-acked: needs: [promote-nightly-next, await-release-surface-ack] # Legacy condition retained for workflow-contract compatibility; approval is retired. # if: needs.await-release-surface-ack.result == 'success' && needs.promote-nightly-next.outputs.action == 'needs-ack' && needs.promote-nightly-next.outputs.dry_run != 'true' if: false # Holds only the release App credentials (CMUX_RELEASE_APP_*). The approval # was given to release-surface-ack on this run; guard.approvedBy() reads it. environment: nightly-next-promote runs-on: ${{ github.repository_owner != 'manaflow-ai' && 'ubuntu-24.04' || 'blacksmith-2vcpu-ubuntu-2404' }} timeout-minutes: 10 permissions: contents: read actions: read # Never in a cancelling group: a later request must not cancel a move. The # move is a compare-and-swap on nightly-next (one fast-forward ref update), # so a concurrent promotion is safe. concurrency: group: nightly-next-promote-ack-${{ github.run_id }} cancel-in-progress: false steps: - name: Checkout the promote guard from main uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: ref: ${{ github.sha }} persist-credentials: false sparse-checkout: | scripts/ci/promote-nightly-next.mjs scripts/ci/nightly_next_train.py .github/release-surface.txt sparse-checkout-cone-mode: false - name: Decide again and check the approver id: ack uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 env: REQUESTED_SHA: ${{ needs.promote-nightly-next.outputs.sha }} EXPECTED_CURRENT: ${{ needs.promote-nightly-next.outputs.current }} with: script: | const fs = require('fs'); const guard = await import(`${process.env.GITHUB_WORKSPACE}/scripts/ci/promote-nightly-next.mjs`); const { owner, repo } = context.repo; const decision = await guard.decide({ github, core, owner, repo, // This job checks an approval, so it needs G3 and G4. requireApproval: true, sha: (process.env.REQUESTED_SHA || '').trim(), surfaceText: fs.readFileSync(`${process.env.GITHUB_WORKSPACE}/.github/release-surface.txt`, 'utf8'), verifyTrain: guard.trainVerifier(`${process.env.GITHUB_WORKSPACE}/scripts/ci/nightly_next_train.py`, { cwd: process.env.GITHUB_WORKSPACE }), }); const approvers = await guard.approvedBy({ github, owner, repo, runId: context.runId }); const policy = guard.parsePolicy(fs.readFileSync(`${process.env.GITHUB_WORKSPACE}/.github/release-surface.txt`, 'utf8')); const result = guard.ackDecision({ decision, approvers, requireNonLander: policy.require_non_lander_approver, triggeringActor: context.payload?.sender?.login || process.env.GITHUB_TRIGGERING_ACTOR, expectedCurrent: process.env.EXPECTED_CURRENT, }); await core.summary.addRaw(guard.summary(decision)).addRaw(`\n- approval: ${result.reason}\n`).write(); if (!result.ok) { core.setFailed(result.reason); return; } if (result.move) core.setOutput('decision', JSON.stringify(decision)); core.notice(result.reason); - name: Mint release App token id: app-token if: steps.ack.outputs.decision != '' uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 with: app-id: ${{ secrets.CMUX_RELEASE_APP_ID }} private-key: ${{ secrets.CMUX_RELEASE_APP_KEY }} owner: ${{ github.repository_owner }} repositories: ${{ github.event.repository.name }} permission-contents: write permission-workflows: write - name: Move nightly-next if: steps.ack.outputs.decision != '' uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 env: APP_TOKEN: ${{ steps.app-token.outputs.token }} DECISION: ${{ steps.ack.outputs.decision }} with: script: | const guard = await import(`${process.env.GITHUB_WORKSPACE}/scripts/ci/promote-nightly-next.mjs`); const { owner, repo } = context.repo; const writer = getOctokit(process.env.APP_TOKEN); core.notice(await guard.move({ writer, github, owner, repo, decision: JSON.parse(process.env.DECISION) })); probe-nightly-tag-permission: permissions: contents: write attestations: write id-token: write if: github.event_name == 'workflow_dispatch' && (inputs.probe_mode || 'off') != 'off' uses: ./.github/workflows/nightly-tag-permission-probe.yml with: target_sha: ${{ inputs.probe_target_sha || github.sha }} mode: ${{ inputs.probe_mode }} secrets: inherit refresh-compilation-cache: permissions: contents: write attestations: write id-token: write needs: decide # The schedule skips this Blacksmith warmer while owned pools serve pull # requests (CI_PR_POOL_OWNED is 1). build-nightly-app saves the same key on # every published main push, so the remaining Blacksmith readers (main's # full-suite dispatch, nightly's Blacksmith fallback) restore at worst an # older entry by prefix. A seed_only dispatch still warms it on demand. if: github.event_name == 'schedule' && github.event.schedule == '17 */6 * * *' && vars.CI_PR_POOL_OWNED != '1' || github.event_name == 'workflow_dispatch' && inputs.seed_only # Match the PR Release-build runner and Xcode so its compilation cache is reusable. runs-on: macos-26 environment: ${{ github.ref == 'refs/heads/main' && 'ci-cache-writer' || '' }} # This job rebuilds the cache after the store drops it, so it runs cold # by design; scheduled run 35134963192 was cancelled at 45 minutes. timeout-minutes: 90 env: CMUX_CI_XCODE_APP: ${{ vars.CMUX_CI_XCODE_APP_MACOS_26 }} CMUX_CI_REQUIRED_MACOS_SDK_MAJOR: "26" steps: - name: Clear stale git locks (self-hosted reused workspace) shell: bash run: | ws="${GITHUB_WORKSPACE:-$PWD}" rm -f "$ws/.git/index.lock" 2>/dev/null || true if [ -d "$ws/.git/modules" ]; then find "$ws/.git/modules" -type f -name "*.lock" -delete 2>/dev/null || true fi - name: Checkout cache ref uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: persist-credentials: false ref: ${{ needs.decide.outputs.head_sha }} fetch-depth: 0 submodules: recursive - name: Select Xcode run: ./scripts/select-ci-xcode.sh - name: Compute Xcode compilation cache key id: compilation-cache-key run: | set -euo pipefail echo "toolchain=$(xcodebuild -version | shasum -a 256 | awk '{print $1}')" >> "$GITHUB_OUTPUT" - name: Restore Xcode compilation cache id: compilation-cache-restore uses: ./.github/actions/cache-restore with: backend: ${{ inputs.cache_backend != 'default' && inputs.cache_backend || vars.CI_CACHE_BACKEND || 'r2' }} path: build-universal/CompilationCache.noindex key: xcode-compilation-release-${{ runner.os }}-${{ runner.arch }}-${{ steps.compilation-cache-key.outputs.toolchain }}-${{ needs.decide.outputs.head_sha }} restore-keys: | xcode-compilation-release-${{ runner.os }}-${{ runner.arch }}-${{ steps.compilation-cache-key.outputs.toolchain }}- - name: Install compilation dependencies if: steps.compilation-cache-restore.outputs.cache-hit != 'true' run: | ./scripts/install-rust-ci.sh ./scripts/install-zig-ci.sh ./scripts/download-prebuilt-ghosttykit.sh - name: Cache Swift packages id: swift-packages-restore if: steps.compilation-cache-restore.outputs.cache-hit != 'true' uses: ./.github/actions/cache-restore with: backend: ${{ inputs.cache_backend != 'default' && inputs.cache_backend || vars.CI_CACHE_BACKEND || 'r2' }} path: .spm-cache key: spm-${{ hashFiles('cmux.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved', 'scripts/ci/swiftpm-cache-layout') }} restore-keys: spm- - name: Sanitize Swift package cache if: steps.compilation-cache-restore.outputs.cache-hit != 'true' run: python3 scripts/ci/sanitize-xcode-source-packages-cache.py .spm-cache - name: Set up Go for the Cloud tunnel extension if: steps.compilation-cache-restore.outputs.cache-hit != 'true' uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 with: go-version: '1.26.x' cache: false - name: Refresh universal nightly compilation cache if: steps.compilation-cache-restore.outputs.cache-hit != 'true' run: | set -euo pipefail CMUX_SKIP_ZIG_BUILD=1 xcodebuild -scheme cmux -configuration Release -derivedDataPath build-universal \ -destination 'generic/platform=macOS' \ -clonedSourcePackagesDirPath .spm-cache \ -packageCachePath "$PWD/.spm-cache/.package-cache" \ -showBuildTimingSummary \ ARCHS="arm64 x86_64" \ ONLY_ACTIVE_ARCH=NO \ COMPILATION_CACHE_ENABLE_CACHING=YES \ COMPILATION_CACHE_LIMIT_SIZE=3221225472 \ COMPILER_INDEX_STORE_ENABLE=NO \ CODE_SIGNING_ALLOWED=NO ASSETCATALOG_COMPILER_APPICON_NAME=AppIcon-Nightly build - name: Bound Xcode compilation cache size id: compilation-cache-bound if: steps.compilation-cache-restore.outputs.cache-hit != 'true' run: | set -euo pipefail cache_path="build-universal/CompilationCache.noindex" max_cache_kib=$((5 * 1024 * 1024)) if [ -d "$cache_path" ]; then # Xcode's CAS chains v1.N generations: the newest is the primary, # the one before it is the upstream it faults objects in from, and # anything older is dead until the CAS's next garbage collection. # The CAS rotates a new primary whenever the current one ends a # build above half of COMPILATION_CACHE_LIMIT_SIZE (1.5 GiB here, # below one build's 3.3 GB working set), so every warm build leaves # a dead generation behind, the directory measures two full builds # (6.6 GB on main), and this bound used to drop every warm cache. # Prune the dead generations first, exactly as the CAS would. python3 scripts/ci/prune-xcode-compilation-cache.py "$cache_path" \ || echo "::warning::Xcode compilation cache pruning failed; measuring it unpruned" cache_kib=$(du -sk "$cache_path" | awk '{print $1}') else cache_kib=0 fi echo "Xcode compilation cache size: ${cache_kib} KiB (limit: ${max_cache_kib} KiB)" save=false if [ "$cache_kib" -gt "$max_cache_kib" ]; then # Nothing is saved, so the next build restores the same older entry # and lands here again: the cache freezes at its last saved entry # until the bound (and COMPILATION_CACHE_LIMIT_SIZE) is raised. echo "::warning::Xcode compilation cache exceeds 5 GiB; skipping cache save" rm -rf "$cache_path" elif [ "$cache_kib" -gt 0 ]; then save=true else echo "Xcode compilation cache is empty; skipping cache save" fi echo "save=${save}" >> "$GITHUB_OUTPUT" - name: Save Xcode compilation cache if: steps.compilation-cache-restore.outputs.cache-hit != 'true' && steps.compilation-cache-bound.outputs.save == 'true' uses: ./.github/actions/cache-save env: # Only an R2 save sees the bucket credentials. AWS_ACCESS_KEY_ID: ${{ (inputs.cache_backend != 'default' && inputs.cache_backend || vars.CI_CACHE_BACKEND) == 'r2' && secrets.CI_CACHE_R2_ACCESS_KEY_ID || '' }} AWS_SECRET_ACCESS_KEY: ${{ (inputs.cache_backend != 'default' && inputs.cache_backend || vars.CI_CACHE_BACKEND) == 'r2' && secrets.CI_CACHE_R2_SECRET_ACCESS_KEY || '' }} CI_CACHE_R2_ENDPOINT: ${{ (inputs.cache_backend != 'default' && inputs.cache_backend || vars.CI_CACHE_BACKEND) == 'r2' && format('https://{0}.r2.cloudflarestorage.com', secrets.CI_CACHE_R2_ACCOUNT_ID) || '' }} CI_CACHE_R2_BUCKET: ${{ vars.CI_CACHE_R2_BUCKET }} with: backend: ${{ inputs.cache_backend != 'default' && inputs.cache_backend || vars.CI_CACHE_BACKEND || 'r2' }} path: build-universal/CompilationCache.noindex key: xcode-compilation-release-${{ runner.os }}-${{ runner.arch }}-${{ steps.compilation-cache-key.outputs.toolchain }}-${{ needs.decide.outputs.head_sha }} - name: Save Swift packages if: steps.compilation-cache-restore.outputs.cache-hit != 'true' && steps.swift-packages-restore.outputs.cache-hit != 'true' uses: ./.github/actions/cache-save env: # Only an R2 save sees the bucket credentials. AWS_ACCESS_KEY_ID: ${{ (inputs.cache_backend != 'default' && inputs.cache_backend || vars.CI_CACHE_BACKEND) == 'r2' && secrets.CI_CACHE_R2_ACCESS_KEY_ID || '' }} AWS_SECRET_ACCESS_KEY: ${{ (inputs.cache_backend != 'default' && inputs.cache_backend || vars.CI_CACHE_BACKEND) == 'r2' && secrets.CI_CACHE_R2_SECRET_ACCESS_KEY || '' }} CI_CACHE_R2_ENDPOINT: ${{ (inputs.cache_backend != 'default' && inputs.cache_backend || vars.CI_CACHE_BACKEND) == 'r2' && format('https://{0}.r2.cloudflarestorage.com', secrets.CI_CACHE_R2_ACCOUNT_ID) || '' }} CI_CACHE_R2_BUCKET: ${{ vars.CI_CACHE_R2_BUCKET }} with: backend: ${{ inputs.cache_backend != 'default' && inputs.cache_backend || vars.CI_CACHE_BACKEND || 'r2' }} path: .spm-cache key: spm-${{ hashFiles('cmux.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved', 'scripts/ci/swiftpm-cache-layout') }} refresh-test-compilation-cache: permissions: contents: write attestations: write id-token: write needs: decide # The schedule skips this Blacksmith seeder while owned pools serve pull # requests (CI_PR_POOL_OWNED is 1): admission then runs on owned minis that # keep their own DerivedData, and seed-derived-data.yml still seeds the # Blacksmith admission pools on main pushes that change app-host build # inputs. Those incremental chains then get no six-hourly cold reset; a # seed_only dispatch still writes one on demand. # Writes the cold DerivedData seed that ci-macos.yml compile admission # adopts, and the Debug compilation cache. Admission no longer restores # that cache (the app target missed on every file; see # tests/test_ci_test_compilation_cache_seed.sh), so it has no reader on # main today. Scheduled, not per push: a build per merge would take macOS # slots from the pull request queue. if: github.event_name == 'schedule' && github.event.schedule == '17 */6 * * *' && vars.CI_PR_POOL_OWNED != '1' || github.event_name == 'workflow_dispatch' && inputs.seed_only # Match the admission job's runner and Xcode. The cache key carries the # toolchain and the build paths, so a mismatch is a miss, not a wrong hit. # Pull requests are what restore this seed, so track the runner the # admission job picks for `pull_request`, not the merge-queue one. On the # 6 vCPU macOS 26 pool, use its 12 vCPU sibling: same image and Xcode, # no pull-request queue (see seed-derived-data.yml). The macOS 15 pool, # which pr_runner_pool.py overflows pull requests to, gets its own cold # seed on its own Xcode too, so its per-push seed chain never carries # stale intermediates for long either (while this schedule runs; see the # CI_PR_POOL_OWNED gate above). Its keys carry that Xcode in the # fingerprint, so the two never write each other's entries. strategy: fail-fast: false matrix: pool: - ${{ github.repository_owner != 'manaflow-ai' && 'macos-26' || vars.MACOS_RUNNER_PR == 'blacksmith-6vcpu-macos-26' && 'blacksmith-12vcpu-macos-26' || vars.MACOS_RUNNER_PR || 'blacksmith-6vcpu-macos-15' }} - ${{ github.repository_owner != 'manaflow-ai' && 'macos-26' || 'blacksmith-6vcpu-macos-15' }} runs-on: ${{ matrix.pool }} environment: ${{ github.ref == 'refs/heads/main' && 'ci-cache-writer' || '' }} timeout-minutes: 75 env: # As seed-derived-data.yml: the macOS 15 pool's own Xcode, else the lane's. CMUX_CI_XCODE_APP: ${{ endsWith(matrix.pool, 'vcpu-macos-15') && vars.CMUX_CI_XCODE_APP_MACOS_15 || vars.CMUX_CI_XCODE_APP_PR || vars.CMUX_CI_XCODE_APP_MACOS_15 }} CMUX_CI_REQUIRED_MACOS_SDK_MAJOR: "26" CMUX_SKIP_ZIG_BUILD: "1" steps: - name: Clear stale git locks (self-hosted reused workspace) shell: bash run: | ws="${GITHUB_WORKSPACE:-$PWD}" rm -f "$ws/.git/index.lock" 2>/dev/null || true if [ -d "$ws/.git/modules" ]; then find "$ws/.git/modules" -type f -name "*.lock" -delete 2>/dev/null || true fi - name: Checkout cache ref uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: persist-credentials: false ref: ${{ needs.decide.outputs.head_sha }} submodules: recursive - name: Prepare admission build paths run: | set -euo pipefail # The same paths as ci.yml `macos-compile-admission`: they are part of # every cache entry this job writes. # A self-hosted Mac can have several runners. Derive one root per # RUNNER_NAME before cleanup so one seed build cannot remove another # runner's source tree, DerivedData, or compilation CAS. root="$(scripts/ci/canonical-build-root.sh --print-root)" scripts/ci/clear-dirs.sh "$root/derived-data-compile-admission" "$root/compile-admission-cas" { echo "CMUX_CI_CANONICAL_ROOT=$root" echo "CMUX_COMPILE_ADMISSION_DERIVED_DATA=$root/derived-data-compile-admission" echo "CMUX_COMPILE_ADMISSION_CAS=$root/compile-admission-cas" } >> "$GITHUB_ENV" - name: Select Xcode run: ./scripts/select-ci-xcode.sh - name: Compute test compilation cache key id: compilation-cache-key run: | set -euo pipefail echo "fingerprint=$(scripts/ci/compile-app-host-test-product.sh canonical-fingerprint "$CMUX_COMPILE_ADMISSION_DERIVED_DATA")" >> "$GITHUB_OUTPUT" - name: Restore test compilation cache id: compilation-cache-restore # Exact key only, so the seed is always one clean build. Restoring the # last seed by prefix stacked another build's objects onto the CAS every # run: the primary generation and the upstream it faults from grew 3.5 -> # 5.0 GiB over eight runs, crossed the bound below, and then no run could # save again. A cold build covers all of main, and measured smaller and # faster than a stacked one. Only a re-run of the same revision hits here. uses: ./.github/actions/cache-restore with: backend: ${{ inputs.cache_backend != 'default' && inputs.cache_backend || vars.CI_CACHE_BACKEND || 'r2' }} path: ${{ env.CMUX_COMPILE_ADMISSION_CAS }} key: xcode-compilation-test-${{ runner.os }}-${{ runner.arch }}-${{ steps.compilation-cache-key.outputs.fingerprint }}-${{ needs.decide.outputs.head_sha }} - name: Install compilation dependencies if: steps.compilation-cache-restore.outputs.cache-hit != 'true' run: | ./scripts/install-rust-ci.sh ./scripts/download-prebuilt-ghosttykit.sh - name: Cache Swift packages id: swift-packages-restore if: steps.compilation-cache-restore.outputs.cache-hit != 'true' uses: ./.github/actions/cache-restore with: backend: ${{ inputs.cache_backend != 'default' && inputs.cache_backend || vars.CI_CACHE_BACKEND || 'r2' }} path: .ci-source-packages key: spm-${{ hashFiles('cmux.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved', 'scripts/ci/swiftpm-cache-layout') }} restore-keys: spm- - name: Sanitize Swift package cache if: steps.compilation-cache-restore.outputs.cache-hit != 'true' run: python3 scripts/ci/sanitize-xcode-source-packages-cache.py .ci-source-packages - name: Resolve Swift packages if: steps.compilation-cache-restore.outputs.cache-hit != 'true' run: | set -euo pipefail scripts/ci/compile-app-host-test-product.sh canonical-resolve \ "$CMUX_COMPILE_ADMISSION_DERIVED_DATA" "$PWD/.ci-source-packages" # ci-macos.yml compile admission adopts this build's DerivedData for pull # requests. The recorded content and times are how it tells which inputs # a later revision changed. See scripts/ci/seed_derived_data.py. - name: Record DerivedData seed inputs id: derived-data-record if: steps.compilation-cache-restore.outputs.cache-hit != 'true' continue-on-error: true run: python3 scripts/ci/seed_derived_data.py record "$CMUX_CI_CANONICAL_SRC" "$CMUX_COMPILE_ADMISSION_DERIVED_DATA" - name: Refresh test compilation cache if: steps.compilation-cache-restore.outputs.cache-hit != 'true' run: | set -euo pipefail scripts/ci/compile-app-host-test-product.sh canonical-build \ "$CMUX_COMPILE_ADMISSION_DERIVED_DATA" \ "$PWD/.ci-source-packages" \ "$CMUX_COMPILE_ADMISSION_CAS" - name: Bound test compilation cache size id: compilation-cache-bound if: steps.compilation-cache-restore.outputs.cache-hit != 'true' run: | set -euo pipefail cache_path="$CMUX_COMPILE_ADMISSION_CAS" max_cache_kib=$((5 * 1024 * 1024)) if [ -d "$cache_path" ]; then # See `refresh-compilation-cache`: a warm build leaves a dead CAS # generation behind, and pruning it keeps the seed to one build. python3 scripts/ci/prune-xcode-compilation-cache.py "$cache_path" \ || echo "::warning::Xcode compilation cache pruning failed; measuring it unpruned" cache_kib=$(du -sk "$cache_path" | awk '{print $1}') else cache_kib=0 fi echo "Test compilation cache size: ${cache_kib} KiB (limit: ${max_cache_kib} KiB)" save=false if [ "$cache_kib" -gt "$max_cache_kib" ]; then echo "::warning::Test compilation cache exceeds 5 GiB; skipping cache save" elif [ "$cache_kib" -gt 0 ]; then save=true else echo "Test compilation cache is empty; skipping cache save" fi echo "save=${save}" >> "$GITHUB_OUTPUT" - name: Save test compilation cache if: steps.compilation-cache-restore.outputs.cache-hit != 'true' && steps.compilation-cache-bound.outputs.save == 'true' uses: ./.github/actions/cache-save env: # Only an R2 save sees the bucket credentials. AWS_ACCESS_KEY_ID: ${{ (inputs.cache_backend != 'default' && inputs.cache_backend || vars.CI_CACHE_BACKEND) == 'r2' && secrets.CI_CACHE_R2_ACCESS_KEY_ID || '' }} AWS_SECRET_ACCESS_KEY: ${{ (inputs.cache_backend != 'default' && inputs.cache_backend || vars.CI_CACHE_BACKEND) == 'r2' && secrets.CI_CACHE_R2_SECRET_ACCESS_KEY || '' }} CI_CACHE_R2_ENDPOINT: ${{ (inputs.cache_backend != 'default' && inputs.cache_backend || vars.CI_CACHE_BACKEND) == 'r2' && format('https://{0}.r2.cloudflarestorage.com', secrets.CI_CACHE_R2_ACCOUNT_ID) || '' }} CI_CACHE_R2_BUCKET: ${{ vars.CI_CACHE_R2_BUCKET }} with: backend: ${{ inputs.cache_backend != 'default' && inputs.cache_backend || vars.CI_CACHE_BACKEND || 'r2' }} path: ${{ env.CMUX_COMPILE_ADMISSION_CAS }} key: xcode-compilation-test-${{ runner.os }}-${{ runner.arch }}-${{ steps.compilation-cache-key.outputs.fingerprint }}-${{ needs.decide.outputs.head_sha }} # canonical-resolve copies the workspace to the canonical root and # resolves there, so the workspace copy is still exactly what was # restored. Save what the resolve wrote, or a fallback restore of an # older layout is saved again under the current key. - name: Collect resolved Swift packages if: steps.compilation-cache-restore.outputs.cache-hit != 'true' && steps.swift-packages-restore.outputs.cache-hit != 'true' run: | set -euo pipefail resolved="$CMUX_CI_CANONICAL_ROOT/src/.ci-source-packages" test -d "$resolved/.package-cache" rsync -a --delete "$resolved/" .ci-source-packages/ - name: Save Swift packages if: steps.compilation-cache-restore.outputs.cache-hit != 'true' && steps.swift-packages-restore.outputs.cache-hit != 'true' uses: ./.github/actions/cache-save env: # Only an R2 save sees the bucket credentials. AWS_ACCESS_KEY_ID: ${{ (inputs.cache_backend != 'default' && inputs.cache_backend || vars.CI_CACHE_BACKEND) == 'r2' && secrets.CI_CACHE_R2_ACCESS_KEY_ID || '' }} AWS_SECRET_ACCESS_KEY: ${{ (inputs.cache_backend != 'default' && inputs.cache_backend || vars.CI_CACHE_BACKEND) == 'r2' && secrets.CI_CACHE_R2_SECRET_ACCESS_KEY || '' }} CI_CACHE_R2_ENDPOINT: ${{ (inputs.cache_backend != 'default' && inputs.cache_backend || vars.CI_CACHE_BACKEND) == 'r2' && format('https://{0}.r2.cloudflarestorage.com', secrets.CI_CACHE_R2_ACCOUNT_ID) || '' }} CI_CACHE_R2_BUCKET: ${{ vars.CI_CACHE_R2_BUCKET }} with: backend: ${{ inputs.cache_backend != 'default' && inputs.cache_backend || vars.CI_CACHE_BACKEND || 'r2' }} path: .ci-source-packages key: spm-${{ hashFiles('cmux.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved', 'scripts/ci/swiftpm-cache-layout') }} # R2 only: a multi-gigabyte entry in the GitHub store evicts the seeds # above (#13160). A failure here never fails the seed. - name: Bound DerivedData seed size id: derived-data-bound if: steps.compilation-cache-restore.outputs.cache-hit != 'true' && steps.derived-data-record.outcome == 'success' && (inputs.cache_backend != 'default' && inputs.cache_backend || vars.CI_CACHE_BACKEND || 'r2') == 'r2' continue-on-error: true run: python3 scripts/ci/seed_derived_data.py prune "$CMUX_COMPILE_ADMISSION_DERIVED_DATA" # Seeds serve only the Swift job width they were built at; see # scripts/ci/seed_derived_data.py. - name: Scope DerivedData seed key id: derived-data-seed-key if: steps.derived-data-bound.outputs.save == 'true' continue-on-error: true env: FINGERPRINT: ${{ steps.compilation-cache-key.outputs.fingerprint }} run: echo "scoped=$(python3 scripts/ci/seed_derived_data.py scope "admission-derived-data-v1-${RUNNER_OS}-${RUNNER_ARCH}-${FINGERPRINT}-")" >> "$GITHUB_OUTPUT" - name: Save DerivedData seed if: steps.derived-data-bound.outputs.save == 'true' && steps.derived-data-seed-key.outputs.scoped != '' continue-on-error: true uses: ./.github/actions/cache-save env: # Only an R2 save sees the bucket credentials. AWS_ACCESS_KEY_ID: ${{ (inputs.cache_backend != 'default' && inputs.cache_backend || vars.CI_CACHE_BACKEND) == 'r2' && secrets.CI_CACHE_R2_ACCESS_KEY_ID || '' }} AWS_SECRET_ACCESS_KEY: ${{ (inputs.cache_backend != 'default' && inputs.cache_backend || vars.CI_CACHE_BACKEND) == 'r2' && secrets.CI_CACHE_R2_SECRET_ACCESS_KEY || '' }} CI_CACHE_R2_ENDPOINT: ${{ (inputs.cache_backend != 'default' && inputs.cache_backend || vars.CI_CACHE_BACKEND) == 'r2' && format('https://{0}.r2.cloudflarestorage.com', secrets.CI_CACHE_R2_ACCOUNT_ID) || '' }} CI_CACHE_R2_BUCKET: ${{ vars.CI_CACHE_R2_BUCKET }} with: backend: r2 path: ${{ env.CMUX_COMPILE_ADMISSION_DERIVED_DATA }} key: ${{ steps.derived-data-seed-key.outputs.scoped }}${{ needs.decide.outputs.head_sha }} build-nightly-ghostty-cli-helper: permissions: contents: write attestations: write id-token: write needs: decide if: needs.decide.outputs.should_build == 'true' && (github.event_name != 'schedule' || github.event.schedule == '47 8 * * *') && needs.decide.outputs.build_only != 'true' # Zig 0.15.2 cannot link the real helper on macOS 26. Match the stable # release workflow by building and verifying it on macOS 15 instead. runs-on: macos-15 timeout-minutes: 20 env: NIGHTLY_FAST_BUILD: ${{ needs.decide.outputs.fast_build }} steps: - name: Clear stale git locks (self-hosted reused workspace) shell: bash run: | ws="${GITHUB_WORKSPACE:-$PWD}" rm -f "$ws/.git/index.lock" 2>/dev/null || true if [ -d "$ws/.git/modules" ]; then find "$ws/.git/modules" -type f -name "*.lock" -delete 2>/dev/null || true fi - name: Checkout helper ref uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: persist-credentials: false ref: ${{ needs.decide.outputs.head_sha }} - name: Initialize Ghostty source submodule shell: bash run: bash scripts/ci/init-submodule.sh ghostty - name: Install Zig run: ./scripts/install-zig-ci.sh - name: Cache Zig packages uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 with: path: ~/.cache/zig key: zig-packages-${{ hashFiles('ghostty/build.zig.zon', 'ghostty/build.zig.zon.json') }} restore-keys: zig-packages- - name: Build Ghostty CLI helper run: | set -euo pipefail mkdir -p nightly-helper if [[ "$NIGHTLY_FAST_BUILD" == "true" ]]; then ./scripts/build-ghostty-cli-helper.sh --target aarch64-macos --output nightly-helper/ghostty lipo nightly-helper/ghostty -verify_arch arm64 else ./scripts/build-ghostty-cli-helper.sh --universal --output nightly-helper/ghostty lipo nightly-helper/ghostty -verify_arch arm64 x86_64 fi - name: Upload Ghostty CLI helper uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: cmux-nightly-ghostty-cli-helper path: nightly-helper/ghostty if-no-files-found: error retention-days: 1 build-nightly-app: permissions: contents: write attestations: write id-token: write needs: decide outputs: daemon_build: ${{ steps.remote_daemon.outputs.build }} daemon_version: ${{ steps.remote_daemon.outputs.version }} if: needs.decide.outputs.should_build == 'true' && (github.event_name != 'schedule' || github.event.schedule == '47 8 * * *') # Owned minis first. Attempt 1 of a push or schedule run on main takes one # trusted owned mini while CI_PR_POOL_OWNED is 1 and both variables below # are set: runs-on asks for the trusted pool label (vars.CI_SEED_TRUSTED_POOL, # glaeda-trusted--xcode-) and that runner's own label # (vars.CI_NIGHTLY_TRUSTED_RUNNER, glaeda-runner-), so only a # runner carrying both can take it. Today that is cmux15-glaeda: the # trusted mini with no pull request runners and no dev-build worker. The # other trusted mini (cmuxs-mac-mini-6) builds team dev builds as the same # user, so it keeps seeding but never builds the shipped app. Its # job-started hook admits only main's own push and schedule jobs. Not the # pull request pool: this job holds the ci-cache-writer R2 keys and the # Sentry token, and its app is what build-sign-notarize-nightly signs and # ships. runner_label_policy.py holds both variables to those shapes. # ci-owned-pool-rescue.yml watches the run (owned_pool_rescue.py, # NIGHTLY_WORKFLOW_PATH): when the mini does not take the job within its # budget, or refuses it at job start, the failed jobs are re-run, and # every later attempt takes Blacksmith below. Either variable empty is # Blacksmith. Signing stays on Blacksmith (build-sign-notarize-nightly). # Blacksmith runs share the cache warmer's and stable release lane's image # and toolchain, which is what the compilation cache is keyed on โ€” OS, arch # and toolchain, never instance size, which is deliberately larger here. # An owned mini keys its own lineage (see the cache key step). Fast # branch dogfood always uses the dedicated Blacksmith image. A # repository-wide runner override may point at a slower shared builder, # which defeats the purpose of the one-architecture path. runs-on: ${{ github.repository_owner != 'manaflow-ai' && 'macos-26' || needs.decide.outputs.fast_build != 'true' && github.run_attempt == 1 && (github.event_name == 'push' || github.event_name == 'schedule') && github.ref == 'refs/heads/main' && vars.CI_PR_POOL_OWNED == '1' && vars.CI_SEED_TRUSTED_POOL != '' && vars.CI_NIGHTLY_TRUSTED_RUNNER != '' && fromJSON(format('["{0}", "{1}"]', vars.CI_SEED_TRUSTED_POOL, vars.CI_NIGHTLY_TRUSTED_RUNNER)) || 'macos-26' }} environment: ${{ github.ref == 'refs/heads/main' && 'ci-cache-writer' || '' }} # The Blacksmith cache is scoped per branch and also drops main's own # entry (runs 35179030871 and 35182663752 restored nothing and were # cancelled at 45 minutes mid-compile), so the budget must fit a cold # build, not only a warm one. timeout-minutes: 90 env: CMUX_CI_XCODE_APP: ${{ vars.CMUX_CI_XCODE_APP_MACOS_26 }} CMUX_CI_REQUIRED_MACOS_SDK_MAJOR: "26" NIGHTLY_FAST_BUILD: ${{ needs.decide.outputs.fast_build }} CHANNEL_ICON_NAME: ${{ needs.decide.outputs.icon_name }} steps: - name: Clear stale git locks (self-hosted reused workspace) shell: bash run: | ws="${GITHUB_WORKSPACE:-$PWD}" rm -f "$ws/.git/index.lock" 2>/dev/null || true if [ -d "$ws/.git/modules" ]; then find "$ws/.git/modules" -type f -name "*.lock" -delete 2>/dev/null || true fi - name: Checkout build ref uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: persist-credentials: false ref: ${{ needs.decide.outputs.head_sha }} submodules: recursive - name: Select Xcode run: ./scripts/select-ci-xcode.sh # A persistent owned mini keeps the workspace between jobs. Checkout's # clean drops untracked output; remove the daemon handoff here too. - name: Clear build outputs a persistent runner kept run: scripts/ci/clear-dirs.sh remote-daemon-assets - name: Start Release DerivedData empty run: | set -euo pipefail # The shipped app never builds on warm DerivedData. An incremental # build can keep stale autolink and force-load data in objects it # did not recompile after an import set changed, and those objects # then link into the app. Every runner starts from an empty tree. # Warm state that stays: the compilation cache restored into it # below (content-keyed, with explicit module dependencies in each # key) and the Swift package checkouts in .spm-cache. scripts/ci/clear-dirs.sh build-universal mkdir -p build-universal case "${RUNNER_NAME:-}" in *-glaeda|*-glaeda-[0-9]*) # Earlier jobs linked build-universal to a tree kept per channel # and toolchain. Delete this channel's trees, so no later change # can link one back and they stop using disk. Another channel's # trees (for example cmux-nightly-rc-*) are not touched. # The name ends in the 64-digit toolchain hash (plus the suffix # clear-dirs.sh adds when a delete did not finish), so the match # never reaches another name with the same prefix. The # nightly-next track keeps its state under # ~/Library/Caches/cmux-nightly-next, which this never touches. shopt -s nullglob stale=() for tree in "$HOME"/Library/Developer/Xcode/DerivedData/cmux-nightly-${{ needs.decide.outputs.channel }}-*; do if [[ "${tree##*/}" =~ ^cmux-nightly-${{ needs.decide.outputs.channel }}-[0-9a-f]{64}(\.stale\..+)?$ ]]; then stale+=("$tree") fi done if [ "${#stale[@]}" -gt 0 ]; then scripts/ci/clear-dirs.sh "${stale[@]}" fi ;; esac - name: Compute Xcode compilation cache key id: compilation-cache-key run: | set -euo pipefail # The key is OS, arch and toolchain. An owned mini (runner # -glaeda[-K]) runs the same Xcode build as Blacksmith but # compiles in another workspace path, so its cache entries would # mostly miss there and push Blacksmith's own entry out of the # prefix restore. Fold its workspace path into the toolchain hash: # each lineage restores only its own, and Blacksmith's key is # unchanged. identity="$(xcodebuild -version)" case "${RUNNER_NAME:-}" in *-glaeda | *-glaeda-[0-9]*) identity="$identity"$'\n'"owned-workspace:$GITHUB_WORKSPACE" ;; esac echo "toolchain=$(printf '%s\n' "$identity" | shasum -a 256 | awk '{print $1}')" >> "$GITHUB_OUTPUT" - name: Restore Xcode compilation cache id: compilation-cache-restore # A cold measurement run skips the restore; every gate below treats the # missing output like a cache miss, so the cold build still saves. if: needs.decide.outputs.cold_cache != 'true' uses: ./.github/actions/cache-restore with: backend: ${{ inputs.cache_backend != 'default' && inputs.cache_backend || vars.CI_CACHE_BACKEND || 'r2' }} path: build-universal/CompilationCache.noindex key: xcode-compilation-release-${{ runner.os }}-${{ runner.arch }}-${{ steps.compilation-cache-key.outputs.toolchain }}-${{ needs.decide.outputs.head_sha }} restore-keys: | xcode-compilation-release-${{ runner.os }}-${{ runner.arch }}-${{ steps.compilation-cache-key.outputs.toolchain }}- - name: Install compilation dependencies run: | ./scripts/install-rust-ci.sh ./scripts/download-prebuilt-ghosttykit.sh # A fast branch dogfood build only needs the arm64 client that is bundled # in this macOS app. Building it here avoids waiting for the full # cross-platform R2 artifact matrix. Main and release keep the immutable # manifest path below, so their machine and distribution artifacts stay # complete. - name: Install Zig for fast cmux-tui client if: env.NIGHTLY_FAST_BUILD == 'true' run: ./scripts/install-zig-ci.sh - name: Build fast arm64 cmux-tui client if: env.NIGHTLY_FAST_BUILD == 'true' working-directory: cmux-tui env: CMUX_TUI_DISTRIBUTION_VERSION: 0.0.0-nightly-${{ needs.decide.outputs.short_sha }} run: | set -euo pipefail unset CMUX_GHOSTTY_SRC CMUX_TUI_BUILD_COMMIT="$(git -C .. rev-parse HEAD)" CMUX_TUI_GHOSTTY_COMMIT="$(git -C ../ghostty rev-parse HEAD)" export CMUX_TUI_BUILD_COMMIT CMUX_TUI_GHOSTTY_COMMIT cargo build -p cmux-tui --bin cmux-tui --release --locked --target aarch64-apple-darwin binary="target/aarch64-apple-darwin/release/cmux-tui" test -x "$binary" probe="$($binary remote-probe --json)" PROBE="$probe" EXPECTED_COMMIT="$CMUX_TUI_BUILD_COMMIT" python3 - <<'PY' import json import os probe = json.loads(os.environ["PROBE"]) expected = os.environ["EXPECTED_COMMIT"] if probe.get("build_identity") != expected: raise SystemExit( f"cmux-tui build identity {probe.get('build_identity')!r} != {expected!r}" ) if "wireguard-hub" not in probe.get("capabilities", []): raise SystemExit("fast cmux-tui client lacks wireguard-hub capability") PY - name: Cache Swift packages id: swift-packages-restore uses: ./.github/actions/cache-restore with: backend: ${{ inputs.cache_backend != 'default' && inputs.cache_backend || vars.CI_CACHE_BACKEND || 'r2' }} path: .spm-cache key: spm-${{ hashFiles('cmux.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved', 'scripts/ci/swiftpm-cache-layout') }} restore-keys: spm- - name: Sanitize Swift package cache run: python3 scripts/ci/sanitize-xcode-source-packages-cache.py .spm-cache - name: Set up Go for the Cloud tunnel extension uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 with: go-version: '1.26.x' cache: false - name: Build nightly app (Release) run: | set -euo pipefail if [[ "$NIGHTLY_FAST_BUILD" == "true" ]]; then archs="arm64" only_active="YES" else archs="arm64 x86_64" only_active="NO" fi # Let xcodebuild fan out across the host. The two whole-module Swift # compiles of the cmux target (about 10 and 16 minutes) miss the # compilation cache on every push because every push changes the # module, and `-jobs 1` made them run back to back: the warm build # step went from 21 minutes to 31-40 (#12704). The diagnostics # wrapper does not need a serial build; PR 12704 recorded 95% free # memory on the 50 GiB host during the failure it was written for. # # The app target's Rust helper phases run after the Swift compile but # do not read its output. Build them now at low priority so those # phases find the local-source helpers fresh instead of building them after # the compile. A failed or late prebuild only leaves that work to the # phases, so its status never fails this step. python3 scripts/ci/run-with-helper-prebuild.py \ --derived-data build-universal --configuration Release --archs "$archs" \ --log "$RUNNER_TEMP/helper-prebuild.log" -- \ ./scripts/ci/run-xcodebuild-with-diagnostics.sh -- \ env CMUX_SKIP_ZIG_BUILD=1 xcodebuild -scheme cmux -configuration Release -derivedDataPath build-universal \ -destination 'generic/platform=macOS' \ -clonedSourcePackagesDirPath .spm-cache \ -packageCachePath "$PWD/.spm-cache/.package-cache" \ -showBuildTimingSummary \ ARCHS="$archs" \ ONLY_ACTIVE_ARCH="$only_active" \ COMPILATION_CACHE_ENABLE_CACHING=YES \ COMPILATION_CACHE_LIMIT_SIZE=3221225472 \ COMPILER_INDEX_STORE_ENABLE=NO \ CODE_SIGNING_ALLOWED=NO ASSETCATALOG_COMPILER_APPICON_NAME="$CHANNEL_ICON_NAME" build - name: Bound Xcode compilation cache size id: compilation-cache-bound run: | set -euo pipefail cache_path="build-universal/CompilationCache.noindex" max_cache_kib=$((5 * 1024 * 1024)) if [ -d "$cache_path" ]; then # Xcode's CAS chains v1.N generations: the newest is the primary, # the one before it is the upstream it faults objects in from, and # anything older is dead until the CAS's next garbage collection. # The CAS rotates a new primary whenever the current one ends a # build above half of COMPILATION_CACHE_LIMIT_SIZE (1.5 GiB here, # below one build's 3.3 GB working set), so every warm build leaves # a dead generation behind, the directory measures two full builds # (6.6 GB on main), and this bound used to drop every warm cache. # Prune the dead generations first, exactly as the CAS would. python3 scripts/ci/prune-xcode-compilation-cache.py "$cache_path" \ || echo "::warning::Xcode compilation cache pruning failed; measuring it unpruned" cache_kib=$(du -sk "$cache_path" | awk '{print $1}') else cache_kib=0 fi echo "Xcode compilation cache size: ${cache_kib} KiB (limit: ${max_cache_kib} KiB)" save=false if [ "$cache_kib" -gt "$max_cache_kib" ]; then # Nothing is saved, so the next build restores the same older entry # and lands here again: the cache freezes at its last saved entry # until the bound (and COMPILATION_CACHE_LIMIT_SIZE) is raised. echo "::warning::Xcode compilation cache exceeds 5 GiB; skipping cache save" rm -rf "$cache_path" elif [ "$cache_kib" -gt 0 ]; then save=true else echo "Xcode compilation cache is empty; skipping cache save" fi echo "save=${save}" >> "$GITHUB_OUTPUT" - name: Save Xcode compilation cache if: steps.compilation-cache-restore.outputs.cache-hit != 'true' && steps.compilation-cache-bound.outputs.save == 'true' uses: ./.github/actions/cache-save env: # Only an R2 save sees the bucket credentials. AWS_ACCESS_KEY_ID: ${{ (inputs.cache_backend != 'default' && inputs.cache_backend || vars.CI_CACHE_BACKEND) == 'r2' && secrets.CI_CACHE_R2_ACCESS_KEY_ID || '' }} AWS_SECRET_ACCESS_KEY: ${{ (inputs.cache_backend != 'default' && inputs.cache_backend || vars.CI_CACHE_BACKEND) == 'r2' && secrets.CI_CACHE_R2_SECRET_ACCESS_KEY || '' }} CI_CACHE_R2_ENDPOINT: ${{ (inputs.cache_backend != 'default' && inputs.cache_backend || vars.CI_CACHE_BACKEND) == 'r2' && format('https://{0}.r2.cloudflarestorage.com', secrets.CI_CACHE_R2_ACCOUNT_ID) || '' }} CI_CACHE_R2_BUCKET: ${{ vars.CI_CACHE_R2_BUCKET }} with: backend: ${{ inputs.cache_backend != 'default' && inputs.cache_backend || vars.CI_CACHE_BACKEND || 'r2' }} path: build-universal/CompilationCache.noindex key: xcode-compilation-release-${{ runner.os }}-${{ runner.arch }}-${{ steps.compilation-cache-key.outputs.toolchain }}-${{ needs.decide.outputs.head_sha }} - name: Save Swift packages if: steps.swift-packages-restore.outputs.cache-hit != 'true' uses: ./.github/actions/cache-save env: # Only an R2 save sees the bucket credentials. AWS_ACCESS_KEY_ID: ${{ (inputs.cache_backend != 'default' && inputs.cache_backend || vars.CI_CACHE_BACKEND) == 'r2' && secrets.CI_CACHE_R2_ACCESS_KEY_ID || '' }} AWS_SECRET_ACCESS_KEY: ${{ (inputs.cache_backend != 'default' && inputs.cache_backend || vars.CI_CACHE_BACKEND) == 'r2' && secrets.CI_CACHE_R2_SECRET_ACCESS_KEY || '' }} CI_CACHE_R2_ENDPOINT: ${{ (inputs.cache_backend != 'default' && inputs.cache_backend || vars.CI_CACHE_BACKEND) == 'r2' && format('https://{0}.r2.cloudflarestorage.com', secrets.CI_CACHE_R2_ACCOUNT_ID) || '' }} CI_CACHE_R2_BUCKET: ${{ vars.CI_CACHE_R2_BUCKET }} with: backend: ${{ inputs.cache_backend != 'default' && inputs.cache_backend || vars.CI_CACHE_BACKEND || 'r2' }} path: .spm-cache key: spm-${{ hashFiles('cmux.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved', 'scripts/ci/swiftpm-cache-layout') }} # The fast arm64 build bundles the client it just compiled. Full builds # take the published client in build-sign-notarize-nightly instead: the # commit is resolved by resolve-nightly-cmux-tui-client while this job # compiles, so the history deepen (1.5 to 11 minutes) is off this path. - name: Bundle the fast cmux-tui client if: env.NIGHTLY_FAST_BUILD == 'true' run: | set -euo pipefail app="build-universal/Build/Products/Release/cmux.app" local_client="$GITHUB_WORKSPACE/cmux-tui/target/aarch64-apple-darwin/release/cmux-tui" test -x "$local_client" CMUX_TUI_CLIENT_LOCAL="$local_client" \ ./scripts/install-cmux-tui-client.sh "$app" \ --require-capability wireguard-hub - name: Build immutable SSH daemon assets once for every app variant if: needs.decide.outputs.build_only != 'true' id: remote_daemon run: | set -euo pipefail app="build-universal/Build/Products/Release/cmux.app" base_version="$(/usr/libexec/PlistBuddy -c 'Print :CFBundleShortVersionString' "$app/Contents/Info.plist")" build="${GITHUB_RUN_ID}$(printf '%02d' "${GITHUB_RUN_ATTEMPT:-1}")" version="${base_version}-${{ needs.decide.outputs.channel }}.${build}" ./scripts/build_remote_daemon_release_assets.sh \ --version "$version" --release-tag "${{ needs.decide.outputs.release_tag }}" \ --repo "$GITHUB_REPOSITORY" --asset-suffix "$build" --output-dir remote-daemon-assets echo "build=$build" >> "$GITHUB_OUTPUT" echo "version=$version" >> "$GITHUB_OUTPUT" - name: Attest immutable SSH daemon assets if: needs.decide.outputs.build_only != 'true' uses: actions/attest-build-provenance@a2bbfa25375fe432b6a289bc6b6cd05ecd0c4c32 # v4.1.0 with: subject-path: remote-daemon-assets/cmuxd-remote-* - name: Upload SSH daemon assets if: needs.decide.outputs.build_only != 'true' uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: cmux-nightly-remote-daemon path: remote-daemon-assets/cmuxd-remote-* if-no-files-found: error retention-days: 14 - name: Strip unsigned nightly app before transfer run: | set -euo pipefail products="build-universal/Build/Products/Release" ./scripts/strip-release-bundle.sh "$products/cmux.app" - name: Verify Cloud tunnel engine before transfer run: | set -euo pipefail app="build-universal/Build/Products/Release/cmux.app" sysext="$(find "$app/Contents/Library/SystemExtensions" -mindepth 1 -maxdepth 1 -name '*.systemextension' -print -quit 2>/dev/null || true)" if [[ -z "$sysext" ]]; then echo "::error::Cloud tunnel system extension is missing" exit 1 fi executable="$(/usr/libexec/PlistBuddy -c 'Print :CFBundleExecutable' "$sysext/Contents/Info.plist")" binary="$sysext/Contents/MacOS/$executable" ./scripts/verify-tunnel-extension-engine.sh "$binary" - name: Archive unsigned nightly app if: needs.decide.outputs.build_only != 'true' run: | set -euo pipefail products="build-universal/Build/Products/Release" if [ ! -d "$products/cmux.app" ]; then echo "Built app not found at $products/cmux.app" >&2 exit 1 fi tar -C "$products" -czf "$RUNNER_TEMP/cmux-nightly-unsigned.tar.gz" cmux.app - name: Upload unsigned nightly app if: needs.decide.outputs.build_only != 'true' uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: cmux-nightly-unsigned-app path: ${{ runner.temp }}/cmux-nightly-unsigned.tar.gz # The tar.gz is already compressed; avoid wasting runner CPU on outer deflate. compression-level: 0 if-no-files-found: error retention-days: 1 - name: Upload dSYMs to Sentry if: needs.decide.outputs.build_only != 'true' env: SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} SENTRY_ORG: manaflow SENTRY_PROJECT: cmuxterm-macos # Retried, and only a warning when Sentry stays down: a nightly is never lost to symbolication. run: ./scripts/upload-sentry-dsyms.sh build-universal/Build/Products/Release/ # Runs beside build-nightly-app. Deepening the shallow clone to find the # newest published cmux-tui commit took 1.5 to 11 minutes after the Xcode # compile (runs 36044415287, 36051668491); here it overlaps the compile. resolve-nightly-cmux-tui-client: needs: decide if: needs.decide.outputs.should_build == 'true' && (github.event_name != 'schedule' || github.event.schedule == '47 8 * * *') && needs.decide.outputs.build_only != 'true' runs-on: ${{ github.repository_owner != 'manaflow-ai' && 'ubuntu-24.04' || 'blacksmith-2vcpu-ubuntu-2404' }} permissions: contents: read timeout-minutes: 30 outputs: commit: ${{ steps.resolve.outputs.commit }} steps: - name: Checkout build ref uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: persist-credentials: false ref: ${{ needs.decide.outputs.head_sha }} submodules: false # The fast arm64 build bundles its own compiled client instead. - name: Resolve cmux-tui client commit id: resolve if: needs.decide.outputs.fast_build != 'true' run: | set -euo pipefail # actions/checkout is depth 1 here, so a bare `git log -1 -- cmux-tui` would answer # HEAD. The resolver deepens as needed and may bundle the newest *published* # client when the latest cmux-tui commit has no artifacts yet (it warns when it does). cmux_tui_commit="$(./scripts/ci/resolve-cmux-tui-client-commit.sh --max-fallback 5)" [[ "$cmux_tui_commit" =~ ^[0-9a-f]{40}$ ]] echo "commit=$cmux_tui_commit" >> "$GITHUB_OUTPUT" prepare-nightly-variant: # No environment and no secrets: this job runs code from the bundle (the # cmux-tui probe, the CLI and helper regressions, the sidecar handshake) # before signing. build-sign-notarize-nightly signs exactly the app it uploads # and runs no bundle code itself. needs: [decide, build-nightly-ghostty-cli-helper, build-nightly-app, resolve-nightly-cmux-tui-client] if: needs.decide.outputs.should_build == 'true' && (github.event_name != 'schedule' || github.event.schedule == '47 8 * * *') && needs.decide.outputs.build_only != 'true' # actions:read lets the parallel artifact download resolve the blob URL. permissions: contents: read actions: read runs-on: macos-26 timeout-minutes: 30 strategy: fail-fast: false matrix: variant: ${{ fromJSON(needs.decide.outputs.variants) }} env: CMUX_CI_XCODE_APP: ${{ vars.CMUX_CI_XCODE_APP_MACOS_26 }} CMUX_CI_REQUIRED_MACOS_SDK_MAJOR: "26" NIGHTLY_VARIANT: ${{ matrix.variant }} NIGHTLY_FAST_BUILD: ${{ needs.decide.outputs.fast_build }} steps: - name: Clear stale git locks (self-hosted reused workspace) shell: bash run: | # Self-hosted macOS runners reuse the workspace. A job cancelled or # killed mid-checkout can leave a stale .git/modules/*/index.lock that # fails every later submodule checkout (e.g. ghostty). Clear them first. ws="${GITHUB_WORKSPACE:-$PWD}" rm -f "$ws/.git/index.lock" 2>/dev/null || true if [ -d "$ws/.git/modules" ]; then find "$ws/.git/modules" -type f -name "*.lock" -delete 2>/dev/null || true fi - name: Checkout build ref uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: persist-credentials: false ref: ${{ needs.decide.outputs.head_sha }} # Main and RC signing do not need submodules. nightly-next's notice # check reads ghostty-next/build.zig.zon, so initialize that optional # submodule in the next step only for the cmux-next track. submodules: false - name: Initialize cmux-next notices submodule if: github.ref_name == 'nightly-next' shell: bash run: bash scripts/ci/init-submodule.sh ghostty-next - name: Select Xcode run: ./scripts/select-ci-xcode.sh # download-artifact reads each blob over one connection. Fetch all three # signing inputs over ranged HTTP concurrently, while retaining the # action downloads as per-artifact fallbacks if a ranged request fails. - name: Download signing inputs (parallel) id: signing-inputs-parallel continue-on-error: true env: GH_TOKEN: ${{ github.token }} run: | set +e mkdir -p nightly-inputs/app nightly-inputs/ghostty remote-daemon-assets python3 scripts/ci/download-run-artifact.py \ --name cmux-nightly-unsigned-app --out nightly-inputs/app \ --connections 64 \ >"$RUNNER_TEMP/nightly-app-download.log" 2>&1 & app_pid=$! python3 scripts/ci/download-run-artifact.py \ --name cmux-nightly-remote-daemon --out remote-daemon-assets \ >"$RUNNER_TEMP/nightly-daemon-download.log" 2>&1 & daemon_pid=$! python3 scripts/ci/download-run-artifact.py \ --name cmux-nightly-ghostty-cli-helper --out nightly-inputs/ghostty \ >"$RUNNER_TEMP/nightly-helper-download.log" 2>&1 & helper_pid=$! wait "$app_pid"; app_status=$? wait "$daemon_pid"; daemon_status=$? wait "$helper_pid"; helper_status=$? for log in \ "$RUNNER_TEMP/nightly-app-download.log" \ "$RUNNER_TEMP/nightly-daemon-download.log" \ "$RUNNER_TEMP/nightly-helper-download.log"; do cat "$log" done app_ok=false; daemon_ok=false; helper_ok=false [ "$app_status" -eq 0 ] && app_ok=true [ "$daemon_status" -eq 0 ] && daemon_ok=true [ "$helper_status" -eq 0 ] && helper_ok=true { echo "app_ok=$app_ok" echo "daemon_ok=$daemon_ok" echo "helper_ok=$helper_ok" } >> "$GITHUB_OUTPUT" exit 0 - name: Download unsigned nightly app if: steps.signing-inputs-parallel.outputs.app_ok != 'true' uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 with: name: cmux-nightly-unsigned-app path: nightly-inputs/app - name: Download SSH daemon assets if: steps.signing-inputs-parallel.outputs.daemon_ok != 'true' uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0 with: name: cmux-nightly-remote-daemon path: remote-daemon-assets - name: Download universal Ghostty CLI helper if: steps.signing-inputs-parallel.outputs.helper_ok != 'true' uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 with: name: cmux-nightly-ghostty-cli-helper path: nightly-inputs/ghostty - name: Restore unsigned nightly products run: | set -euo pipefail products="build-universal/Build/Products/Release" mkdir -p "$products" tar -C "$products" -xzf nightly-inputs/app/cmux-nightly-unsigned.tar.gz - name: Inject universal Ghostty CLI helper run: | set -euo pipefail APP_DIR="build-universal/Build/Products/Release/cmux.app" if [ ! -d "$APP_DIR" ]; then echo "Built app not found at $APP_DIR" >&2 exit 1 fi DEST="$APP_DIR/Contents/Resources/bin/ghostty" mkdir -p "$(dirname "$DEST")" install -m 755 nightly-inputs/ghostty/ghostty "$DEST" echo "Injected Ghostty CLI helper architectures: $(lipo -archs "$DEST")" # Every variant installs the one commit resolve-nightly-cmux-tui-client # chose, from its attested immutable manifest, before thinning. - name: Bundle the cmux-tui client if: needs.decide.outputs.fast_build != 'true' env: # install-cmux-tui-client.sh verifies the manifest's build-provenance # attestation with gh before trusting it. GH_TOKEN: ${{ github.token }} CMUX_TUI_CLIENT_COMMIT: ${{ needs.resolve-nightly-cmux-tui-client.outputs.commit }} # Per-job cache, so legs sharing a host never race on one download. CMUX_TUI_CLIENT_CACHE: ${{ runner.temp }}/cmux-tui-client run: | set -euo pipefail app="build-universal/Build/Products/Release/cmux.app" cmux_tui_commit="$CMUX_TUI_CLIENT_COMMIT" [[ "$cmux_tui_commit" =~ ^[0-9a-f]{40}$ ]] || { echo "resolve-nightly-cmux-tui-client produced no commit" >&2; exit 1; } ./scripts/install-cmux-tui-client.sh \ "$app" \ --manifest-url "https://files.cmux.com/cmux-tui/${cmux_tui_commit}/manifest.json" \ --expected-commit "$cmux_tui_commit" \ --require-capability wireguard-hub # The client and its SSH payloads arrive after the build job's strip, so # strip again; already-stripped binaries are unchanged. ./scripts/strip-release-bundle.sh "$app" - name: Run bundled Ghostty theme picker helper regression run: | set -euo pipefail CMUX_APP_PATH="build-universal/Build/Products/Release/cmux.app" \ ./tests/test_bundled_ghostty_theme_picker_helper.sh - name: Run CLI version memory guard regression run: | set -euo pipefail CLI_BINARY="build-universal/Build/Products/Release/cmux.app/Contents/Resources/bin/cmux" [ -x "$CLI_BINARY" ] || { echo "cmux CLI binary not found at $CLI_BINARY" >&2; exit 1; } CMUX_CLI_BIN="$CLI_BINARY" python3 tests/test_cli_version_memory_guard.py - name: Thin bundle to the variant architecture # The fast build is already compiled arm64, but the downloaded cmux-tui # client can still be universal. Thin every non-universal artifact before # signing so the architecture gate remains meaningful. if: matrix.variant != 'universal' run: | set -euo pipefail ./scripts/thin-app-bundle.sh build-universal/Build/Products/Release/cmux.app "$NIGHTLY_VARIANT" - name: Verify nightly binary architectures run: | set -euo pipefail APP="build-universal/Build/Products/Release/cmux.app" for binary in \ "$APP/Contents/MacOS/cmux" \ "$APP/Contents/Resources/bin/cmux" \ "$APP/Contents/Resources/bin/ghostty" \ "$APP/Contents/Resources/bin/cmux-tui" \ "$APP/Contents/Resources/bin/cmux-diff-sidecar" \ "$APP/Contents/Library/cmux Computer Use.app/Contents/MacOS/cmux-cua" do archs="$(lipo -archs "$binary")" echo "$binary: $archs" if [ "$NIGHTLY_VARIANT" = "universal" ]; then [[ "$archs" == *arm64* && "$archs" == *x86_64* ]] else [ "$archs" = "$NIGHTLY_VARIANT" ] fi done if [ "$NIGHTLY_VARIANT" != "universal" ]; then # No fat Mach-O may survive thinning anywhere in the bundle. fat="$(find "$APP" -type f -exec sh -c 'file -b "$1" | grep -q "Mach-O universal" && echo "$1"' _ {} \;)" if [ -n "$fat" ]; then echo "Fat Mach-O files remain after thinning:" >&2 echo "$fat" >&2 exit 1 fi fi if [ "$NIGHTLY_VARIANT" = "universal" ]; then ./scripts/verify-diff-sidecar-artifact.sh "$APP/Contents/Resources/bin/cmux-diff-sidecar" --archs "arm64 x86_64" else ./scripts/verify-diff-sidecar-artifact.sh "$APP/Contents/Resources/bin/cmux-diff-sidecar" --archs "$NIGHTLY_VARIANT" fi - name: Package prepared app for signing run: | set -euo pipefail mkdir -p prepared-app-handoff tar -C build-universal/Build/Products/Release -czf prepared-app-handoff/cmux-prepared-app.tar.gz cmux.app # nightly-sign.yml (run from main) reads this annotation of this job # through the checks API and refuses a tarball with another sha256. - name: Record prepared app digest env: PREPARED_APP_ARTIFACT: cmux-${{ needs.decide.outputs.channel }}-prepared-app-${{ matrix.variant }}-${{ needs.decide.outputs.short_sha }} NIGHTLY_SHOULD_PUBLISH: ${{ needs.decide.outputs.should_publish }} run: | set -euo pipefail digest="$(shasum -a 256 prepared-app-handoff/cmux-prepared-app.tar.gz | awk '{print $1}')" [[ "$digest" =~ ^[0-9a-f]{64}$ ]] || { echo "no sha256 for the prepared app" >&2; exit 1; } echo "::notice title=nightly-prepared-app::variant=${NIGHTLY_VARIANT} artifact=${PREPARED_APP_ARTIFACT} sha256=${digest} publish=${NIGHTLY_SHOULD_PUBLISH} fast=${NIGHTLY_FAST_BUILD}" - name: Upload prepared app for signing uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: cmux-${{ needs.decide.outputs.channel }}-prepared-app-${{ matrix.variant }}-${{ needs.decide.outputs.short_sha }} path: prepared-app-handoff/cmux-prepared-app.tar.gz compression-level: 0 if-no-files-found: error retention-days: 3 build-sign-notarize-nightly: # Production secrets: GitHub releases them only to protected refs (the # environment's deployment branch policy: main, tags v*). environment: release needs: [decide, build-nightly-app, prepare-nightly-variant] if: needs.decide.outputs.should_build == 'true' && (github.event_name != 'schedule' || github.event.schedule == '47 8 * * *') && needs.decide.outputs.build_only != 'true' # Sign and notarize on the same macOS 26 lane proven by stable releases. permissions: contents: read actions: read # A literal ephemeral label: a repository variable must not move a signing # job onto a machine that is reused between jobs. runs-on: ${{ github.repository_owner != 'manaflow-ai' && 'macos-26' || (needs.decide.outputs.fast_build == 'true' && 'macos-26' || 'macos-26') }} # Start the standalone helper submission early so # Apple's first wait overlaps useful work; finish it before the final DMG. # Sparkle delta generation runs after the full publication in a separate # best-effort lane, so it cannot delay the first update users can install. # Apple may leave the independently submitted Computer Use helper in its # queue for close to an hour before the final DMG can be created. Keep the # runner lease bounded, but leave enough room for the helper wait and the # signed DMG validation that follows it. timeout-minutes: ${{ needs.decide.outputs.should_publish == 'true' && 120 || 170 }} # One universal Xcode build feeds every variant: the thin variants are cut # from it with lipo, so each user downloads only their architecture. strategy: fail-fast: false matrix: variant: ${{ fromJSON(needs.decide.outputs.variants) }} env: CMUX_CI_XCODE_APP: ${{ vars.CMUX_CI_XCODE_APP_MACOS_26 }} CMUX_CI_REQUIRED_MACOS_SDK_MAJOR: "26" NIGHTLY_VARIANT: ${{ matrix.variant }} NIGHTLY_FAST_BUILD: ${{ needs.decide.outputs.fast_build }} NIGHTLY_BUILD: ${{ needs.build-nightly-app.outputs.daemon_build }} NIGHTLY_MARKETING_VERSION: ${{ needs.build-nightly-app.outputs.daemon_version }} BUNDLE_REMOTE_DAEMON_ASSETS: ${{ needs.decide.outputs.fast_build == 'true' || needs.decide.outputs.should_publish != 'true' }} CHANNEL: ${{ needs.decide.outputs.channel }} CHANNEL_BUNDLE_ID: ${{ needs.decide.outputs.bundle_id }} CHANNEL_APP_NAME: ${{ needs.decide.outputs.app_name }} CHANNEL_URL_SCHEME: ${{ needs.decide.outputs.url_scheme }} CHANNEL_DMG_PREFIX: ${{ needs.decide.outputs.dmg_prefix }} CHANNEL_RELEASE_TAG: ${{ needs.decide.outputs.release_tag }} CHANNEL_FEED_BASE: ${{ needs.decide.outputs.feed_base }} CHANNEL_ENTITLEMENTS: ${{ needs.decide.outputs.entitlements }} # Signed app path for every step after "Prepare channel identity". CHANNEL_APP_PATH: build-universal/Build/Products/Release/${{ needs.decide.outputs.app_name }}.app steps: - name: Clear stale git locks (self-hosted reused workspace) shell: bash run: | # Self-hosted macOS runners reuse the workspace. A job cancelled or # killed mid-checkout can leave a stale .git/modules/*/index.lock that # fails every later submodule checkout (e.g. ghostty). Clear them first. ws="${GITHUB_WORKSPACE:-$PWD}" rm -f "$ws/.git/index.lock" 2>/dev/null || true if [ -d "$ws/.git/modules" ]; then find "$ws/.git/modules" -type f -name "*.lock" -delete 2>/dev/null || true fi - name: Checkout build ref uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: persist-credentials: false ref: ${{ needs.decide.outputs.head_sha }} # Main and RC signing do not need submodules. nightly-next's notice # check reads ghostty-next/build.zig.zon, so initialize that optional # submodule in the next step only for the cmux-next track. submodules: false - name: Initialize cmux-next notices submodule if: github.ref_name == 'nightly-next' shell: bash run: bash scripts/ci/init-submodule.sh ghostty-next - name: Select Xcode run: ./scripts/select-ci-xcode.sh - name: Download prepared app uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0 with: name: cmux-${{ needs.decide.outputs.channel }}-prepared-app-${{ matrix.variant }}-${{ needs.decide.outputs.short_sha }} path: prepared-app - name: Download SSH daemon assets uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0 with: name: cmux-nightly-remote-daemon path: remote-daemon-assets - name: Restore prepared app run: | set -euo pipefail products="build-universal/Build/Products/Release" mkdir -p "$products" tar -C "$products" -xzf prepared-app/cmux-prepared-app.tar.gz # Provide node/npm explicitly. The build/sign/notarize job can land on a # self-hosted macOS runner (the iOS-CI minis match the same label) that has # no npm on PATH, so `npm install --global create-dmg` failed with # "npm: command not found" and turned nightly red. setup-node makes the job # self-sufficient on any runner instead of assuming a preinstalled npm. - name: Set up Node uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 with: node-version: "20" - name: Install build deps run: | CMUX_NODE_BIN="$(command -v node)" export npm_config_prefix="$RUNNER_TEMP/npm-global" mkdir -p "$npm_config_prefix" npm install --global "create-dmg@${CREATE_DMG_VERSION}" wrapper_dir="$RUNNER_TEMP/create-dmg-wrapper" mkdir -p "$wrapper_dir" cat > "$wrapper_dir/create-dmg" <> "$GITHUB_PATH" - name: Derive Sparkle public key from private key env: # scripts/ci/nightly-sparkle-key.sh picks the key per channel and # NIGHTLY_SPARKLE_KEY; unset keeps the shared key. NIGHTLY_SPARKLE_KEY: ${{ vars.NIGHTLY_SPARKLE_KEY }} CHANNEL: ${{ needs.decide.outputs.channel }} SHARED_SPARKLE_PRIVATE_KEY: ${{ secrets.SPARKLE_PRIVATE_KEY }} NIGHTLY_SPARKLE_PRIVATE_KEY: ${{ secrets.NIGHTLY_SPARKLE_PRIVATE_KEY }} run: | set -euo pipefail SPARKLE_PRIVATE_KEY="$(./scripts/ci/nightly-sparkle-key.sh)" DERIVED_PUBLIC_KEY=$( printf '%s' "$SPARKLE_PRIVATE_KEY" | env -u SPARKLE_PRIVATE_KEY \ -u SHARED_SPARKLE_PRIVATE_KEY \ -u NIGHTLY_SPARKLE_PRIVATE_KEY \ swift scripts/derive_sparkle_public_key.swift --stdin ) echo "Derived Sparkle public key: $DERIVED_PUBLIC_KEY" echo "SPARKLE_PUBLIC_KEY=$DERIVED_PUBLIC_KEY" >> "$GITHUB_ENV" - name: Inject nightly identities and metadata run: | set -euo pipefail SHORT_SHA="${{ needs.decide.outputs.short_sha }}" APP_DIR="build-universal/Build/Products/Release" # The producer owns the version for both the daemon and every app variant. : "${NIGHTLY_BUILD:?missing daemon build}" "${NIGHTLY_MARKETING_VERSION:?missing daemon version}" NIGHTLY_DMG_IMMUTABLE="${CHANNEL_DMG_PREFIX}-${NIGHTLY_VARIANT}-${NIGHTLY_BUILD}.dmg" NIGHTLY_DMG_RELEASE="${CHANNEL_DMG_PREFIX}-${NIGHTLY_VARIANT}.dmg" # Thin variants get their own feed. The universal variant keeps the legacy # feed name; the app's feed resolver rewrites it to the per-architecture # feed at runtime, which is how older installs migrate. if [ "$NIGHTLY_VARIANT" = "universal" ]; then NIGHTLY_APPCAST="appcast.xml" else NIGHTLY_APPCAST="appcast-${NIGHTLY_VARIANT}.xml" fi { echo "NIGHTLY_BUILD=${NIGHTLY_BUILD}" echo "NIGHTLY_MARKETING_VERSION=${NIGHTLY_MARKETING_VERSION}" echo "NIGHTLY_DMG_IMMUTABLE=${NIGHTLY_DMG_IMMUTABLE}" echo "NIGHTLY_DMG_RELEASE=${NIGHTLY_DMG_RELEASE}" echo "NIGHTLY_APPCAST=${NIGHTLY_APPCAST}" } >> "$GITHUB_ENV" prepare_variant() { local app_dir="$1" local bundle_id="$2" local feed_url="$3" local app_plist="$app_dir/cmux.app/Contents/Info.plist" /usr/libexec/PlistBuddy -c "Set :CFBundleName ${CHANNEL_APP_NAME}" "$app_plist" /usr/libexec/PlistBuddy -c "Set :CFBundleDisplayName ${CHANNEL_APP_NAME}" "$app_plist" /usr/libexec/PlistBuddy -c "Set :CFBundleIdentifier ${bundle_id}" "$app_plist" local url_type_name url_type_name="$(/usr/libexec/PlistBuddy -c "Print :CFBundleURLTypes:1:CFBundleURLName" "$app_plist")" if [[ "$url_type_name" != *.auth ]]; then echo "Expected CFBundleURLTypes[1] to be the auth URL type, found: $url_type_name" >&2 exit 1 fi /usr/libexec/PlistBuddy -c "Set :CFBundleURLTypes:1:CFBundleURLSchemes:0 ${CHANNEL_URL_SCHEME}" "$app_plist" /usr/libexec/PlistBuddy -c "Delete :SUPublicEDKey" "$app_plist" >/dev/null 2>&1 || true /usr/libexec/PlistBuddy -c "Delete :SUFeedURL" "$app_plist" >/dev/null 2>&1 || true /usr/libexec/PlistBuddy -c "Add :SUPublicEDKey string ${SPARKLE_PUBLIC_KEY}" "$app_plist" /usr/libexec/PlistBuddy -c "Add :SUFeedURL string ${feed_url}" "$app_plist" /usr/libexec/PlistBuddy -c "Set :CFBundleShortVersionString ${NIGHTLY_MARKETING_VERSION}" "$app_plist" /usr/libexec/PlistBuddy -c "Set :CFBundleVersion ${NIGHTLY_BUILD}" "$app_plist" /usr/libexec/PlistBuddy -c "Delete :CMUXCommit" "$app_plist" >/dev/null 2>&1 || true /usr/libexec/PlistBuddy -c "Add :CMUXCommit string ${SHORT_SHA}" "$app_plist" # The bundled Cloud tunnel extension follows the app's identity: # .tunnel, team-prefixed mach service, same version. local sysext sysext="$(find "$app_dir/cmux.app/Contents/Library/SystemExtensions" -mindepth 1 -maxdepth 1 -name '*.systemextension' -print -quit 2>/dev/null || true)" if [[ -n "$sysext" ]]; then local sysext_plist="$sysext/Contents/Info.plist" /usr/libexec/PlistBuddy -c "Set :CFBundleIdentifier ${bundle_id}.tunnel" "$sysext_plist" /usr/libexec/PlistBuddy -c "Set :NetworkExtension:NEMachServiceName 7WLXT3NR37.${bundle_id}.tunnel" "$sysext_plist" /usr/libexec/PlistBuddy -c "Set :CFBundleShortVersionString ${NIGHTLY_MARKETING_VERSION}" "$sysext_plist" /usr/libexec/PlistBuddy -c "Set :CFBundleVersion ${NIGHTLY_BUILD}" "$sysext_plist" ./scripts/normalize-system-extension-bundle.sh "$app_dir/cmux.app" "${bundle_id}.tunnel" fi mv "$app_dir/cmux.app" "$app_dir/${CHANNEL_APP_NAME}.app" } prepare_variant \ "$APP_DIR" \ "$CHANNEL_BUNDLE_ID" \ "${CHANNEL_FEED_BASE}/${NIGHTLY_APPCAST}" echo "Channel: ${CHANNEL}" echo "App name: ${CHANNEL_APP_NAME}" echo "Variant: ${NIGHTLY_VARIANT}" echo "Feed: ${CHANNEL_FEED_BASE}/${NIGHTLY_APPCAST}" echo "Bundle ID: ${CHANNEL_BUNDLE_ID}" echo "Marketing version: ${NIGHTLY_MARKETING_VERSION}" echo "Build number: ${NIGHTLY_BUILD}" echo "Immutable DMG: ${NIGHTLY_DMG_IMMUTABLE}" echo "Commit SHA: ${SHORT_SHA}" - name: Embed verified SSH daemon manifest run: | bundle_args=() if [[ "$BUNDLE_REMOTE_DAEMON_ASSETS" == "true" ]]; then # Unpublished dogfood builds must work without GitHub release URLs. bundle_args+=(--bundle-assets) fi python3 scripts/verify_remote_daemon_release.py \ --manifest "remote-daemon-assets/cmuxd-remote-manifest-${NIGHTLY_BUILD}.json" \ --assets-dir remote-daemon-assets \ --app "$CHANNEL_APP_PATH" --embed "${bundle_args[@]}" # The signing keychain exists only while this job signs and is deleted # after the DMG signature. This job runs no code from the bundle; # scripts/ci/signing-keychain.sh owns the keychain's lifetime. - name: Import signing cert env: APPLE_CERTIFICATE_BASE64: ${{ secrets.APPLE_CERTIFICATE_BASE64 }} APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }} run: ./scripts/ci/signing-keychain.sh create - name: Start Computer Use helper notarization if: needs.decide.outputs.fast_build != 'true' env: ASC_API_KEY_ID: ${{ secrets.ASC_API_KEY_ID }} ASC_API_ISSUER_ID: ${{ secrets.ASC_API_ISSUER_ID }} ASC_API_KEY_P8_BASE64: ${{ secrets.ASC_API_KEY_P8_BASE64 }} APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }} run: | state="$RUNNER_TEMP/cmux-computer-use-notarization.state" rm -f "$state" ./scripts/ci/notarize-computer-use-helper.sh \ --start "$state" \ "$CHANNEL_APP_PATH" \ "$CHANNEL_ENTITLEMENTS" \ "$APPLE_SIGNING_IDENTITY" # Each channel has its own Developer ID profiles: # APPLE_NIGHTLY_PROVISIONING_PROFILE_BASE64 / APPLE_RC_PROVISIONING_PROFILE_BASE64 # for the app and the matching *_TUNNEL_* secret for the system extension. - name: Embed channel provisioning profile env: APP_PROVISIONING_PROFILE_BASE64: ${{ secrets[format('APPLE_{0}_PROVISIONING_PROFILE_BASE64', needs.decide.outputs.channel_upper)] }} PROFILE_SECRET_NAME: APPLE_${{ needs.decide.outputs.channel_upper }}_PROVISIONING_PROFILE_BASE64 run: | if [ -z "$APP_PROVISIONING_PROFILE_BASE64" ]; then echo "Missing $PROFILE_SECRET_NAME secret" >&2 exit 1 fi APP_PATH="$CHANNEL_APP_PATH" PROFILE_PATH="$APP_PATH/Contents/embedded.provisionprofile" TMP_PROFILE="$(mktemp /tmp/cmux-channel-profile.XXXXXX)" TMP_PLIST="$(mktemp /tmp/cmux-channel-profile.XXXXXX.plist)" trap 'rm -f "$TMP_PROFILE" "$TMP_PLIST"' EXIT echo "$APP_PROVISIONING_PROFILE_BASE64" | base64 --decode > "$TMP_PROFILE" security cms -D -i "$TMP_PROFILE" > "$TMP_PLIST" APP_ID="$(/usr/libexec/PlistBuddy -c "Print :Entitlements:com.apple.application-identifier" "$TMP_PLIST")" if [ "$APP_ID" != "7WLXT3NR37.${CHANNEL_BUNDLE_ID}" ]; then echo "$CHANNEL provisioning profile targets unexpected app ID: $APP_ID" >&2 exit 1 fi # The WebAuthn browser entitlement is an Apple-approved capability # request. Nightly has it; the RC App ID's request has been pending # since 2026-07-10, so cmux.rc.entitlements does not ask for it and # RC ships without passkey sign-in in the embedded browser until Apple # approves. The check follows the channel's entitlements file so the # profile can never silently lag behind what the app requests. if /usr/libexec/PlistBuddy -c "Print :com.apple.developer.web-browser.public-key-credential" "$CHANNEL_ENTITLEMENTS" >/dev/null 2>&1; then WEBAUTHN_ENTITLEMENT="$(/usr/libexec/PlistBuddy -c "Print :Entitlements:com.apple.developer.web-browser.public-key-credential" "$TMP_PLIST" 2>/dev/null || true)" if [ "$WEBAUTHN_ENTITLEMENT" != "true" ]; then echo "$CHANNEL provisioning profile is missing WebAuthn browser entitlement" >&2 exit 1 fi else echo "$CHANNEL entitlements do not request the WebAuthn browser entitlement; skipping the profile check" fi PROVISIONS_ALL_DEVICES="$(/usr/libexec/PlistBuddy -c "Print :ProvisionsAllDevices" "$TMP_PLIST")" if [ "$PROVISIONS_ALL_DEVICES" != "true" ]; then echo "$CHANNEL provisioning profile is not a Developer ID all-devices profile" >&2 exit 1 fi cp "$TMP_PROFILE" "$PROFILE_PATH" - name: Embed Cloud tunnel extension provisioning profile env: TUNNEL_PROVISIONING_PROFILE_BASE64: ${{ secrets[format('APPLE_{0}_TUNNEL_PROVISIONING_PROFILE_BASE64', needs.decide.outputs.channel_upper)] }} run: | set -euo pipefail ./scripts/ci/embed-tunnel-extension-profile.sh \ "$CHANNEL_APP_PATH" \ "7WLXT3NR37.${CHANNEL_BUNDLE_ID}.tunnel" \ "${TUNNEL_PROVISIONING_PROFILE_BASE64:-}" - name: Codesign apps env: APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }} run: | if [ -z "$APPLE_SIGNING_IDENTITY" ]; then echo "Missing APPLE_SIGNING_IDENTITY secret" >&2 exit 1 fi # Published Nightly and RC builds preserve the split helper ticket. # Fast dogfood has no Apple ticket, so sign the nested helper in the # normal inside-out pass instead of leaving it unsigned. if [ "$NIGHTLY_FAST_BUILD" = "true" ]; then sign_mode=all else sign_mode=all-except-computer-use fi CMUX_SIGN_MODE="$sign_mode" \ ./scripts/sign-cmux-bundle.sh \ "$CHANNEL_APP_PATH" \ "$CHANNEL_ENTITLEMENTS" \ "$APPLE_SIGNING_IDENTITY" - name: Verify bundled Mach-O load commands run: ./scripts/verify-bundle-load-commands.sh "$CHANNEL_APP_PATH" # This job holds signing secrets, so it runs no code from the bundle. # verify-signed-nightly runs every check that executes the app, the CLI, # the helpers or the sidecar, in a job without secrets, on this exact # signed app (and, for the inline path, on the final DMG). - name: Package signed app for the verify job run: | set -euo pipefail mkdir -p signed-app-handoff tar -C "$(dirname "$CHANNEL_APP_PATH")" -czf signed-app-handoff/cmux-signed-app.tar.gz "$(basename "$CHANNEL_APP_PATH")" (cd signed-app-handoff && shasum -a 256 cmux-signed-app.tar.gz > cmux-signed-app.tar.gz.sha256) - name: Upload signed app for the verify job uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: cmux-${{ needs.decide.outputs.channel }}-signed-app-${{ matrix.variant }}-${{ needs.decide.outputs.short_sha }} path: signed-app-handoff/* compression-level: 0 if-no-files-found: error retention-days: 3 - name: Notarize app ticket through final DMG id: notarize-nightly if: needs.decide.outputs.fast_build != 'true' # Computer Use helper propagation can still take about 20 minutes; # this budget covers that work and DMG validation, not Apple's outer # notarization queue, which is asynchronous now for a published build. # A build that does not publish waits for both DMG submissions here. timeout-minutes: ${{ needs.decide.outputs.should_publish == 'true' && 90 || 140 }} env: ASC_API_KEY_ID: ${{ secrets.ASC_API_KEY_ID }} ASC_API_ISSUER_ID: ${{ secrets.ASC_API_ISSUER_ID }} ASC_API_KEY_P8_BASE64: ${{ secrets.ASC_API_KEY_P8_BASE64 }} APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }} # A build that publishes submits only (resume-stapled-app-notarization.sh # continues it and signs there), so it gets no certificate. Any other # build waits here and then signs and notarizes the DMG around the # stapled app (CMUX_STAPLE_APP_IN_DMG). APPLE_CERTIFICATE_BASE64: ${{ needs.decide.outputs.should_publish != 'true' && secrets.APPLE_CERTIFICATE_BASE64 || '' }} APPLE_CERTIFICATE_PASSWORD: ${{ needs.decide.outputs.should_publish != 'true' && secrets.APPLE_CERTIFICATE_PASSWORD || '' }} CMUX_STAPLED_DMG_WAIT_TIMEOUT: 25m CMUX_NOTARY_SUBMIT_ONLY: ${{ needs.decide.outputs.should_publish }} CMUX_DEFER_GATEKEEPER_ASSESSMENT: ${{ needs.decide.outputs.should_publish }} CMUX_COMPUTER_USE_NOTARY_SUBMISSION_FILE: ${{ runner.temp }}/cmux-computer-use-notarization.state CMUX_CHANNEL: ${{ needs.decide.outputs.channel }} # The final-DMG launch smokes run in verify-signed-nightly. CMUX_DEFER_LAUNCH_SMOKES: "true" run: | ./scripts/ci/notarize-nightly-dmg.sh \ "$CHANNEL_APP_PATH" \ "$NIGHTLY_DMG_RELEASE" \ "$NIGHTLY_DMG_IMMUTABLE" - name: Package signed fast dogfood DMG if: needs.decide.outputs.fast_build == 'true' env: APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }} CMUX_CHANNEL: ${{ needs.decide.outputs.channel }} CMUX_SKIP_NOTARIZATION: "true" run: | ./scripts/ci/notarize-nightly-dmg.sh \ "$CHANNEL_APP_PATH" \ "$NIGHTLY_DMG_RELEASE" \ "$NIGHTLY_DMG_IMMUTABLE" - name: Delete signing keychain after the DMG signature if: always() run: ./scripts/ci/signing-keychain.sh delete # Submit the exact signed DMG and hand Apple processing to the trusted # workflow_run continuation. If the nested helper is still queued, no # DMG exists yet; preserve that exact helper submission and signed app. - name: Prepare pending notarization recovery artifact id: prepare-notarization-recovery if: always() && (steps.notarize-nightly.outputs.submission_pending == 'true' || steps.notarize-nightly.outcome == 'failure') run: | set -euo pipefail recovery_kind=dmg recovery_state="$NIGHTLY_DMG_RELEASE.notarization.state" recovery_log="$NIGHTLY_DMG_RELEASE.notarization.log" if [ ! -e "$NIGHTLY_DMG_RELEASE" ]; then helper_state="$RUNNER_TEMP/cmux-computer-use-notarization.state" helper_log="${helper_state}.log" for required in "$helper_state" "$helper_log" "$CHANNEL_APP_PATH"; do if [ ! -e "$required" ]; then echo "Missing helper notarization recovery input: $required" >&2 exit 1 fi done recovery_kind=computer-use-helper recovery_state="$helper_state" recovery_log="$helper_log" else for required in "$NIGHTLY_DMG_RELEASE" "$recovery_state" "$recovery_log" "$CHANNEL_APP_PATH"; do if [ ! -e "$required" ]; then echo "Missing notarization recovery input: $required" >&2 exit 1 fi done fi recovery_app_archive="cmux-nightly-notarization-recovery-app.tar.gz" rm -f "$recovery_app_archive" tar -C "$(dirname "$CHANNEL_APP_PATH")" -czf "$recovery_app_archive" "$(basename "$CHANNEL_APP_PATH")" recovery_staging="cmux-nightly-notarization-recovery-staging" rm -rf "$recovery_staging" mkdir -p "$recovery_staging" metadata_staging="cmux-nightly-notarization-metadata-staging" rm -rf "$metadata_staging" mkdir -p "$metadata_staging" echo "kind=$recovery_kind" >> "$GITHUB_OUTPUT" if [ "$recovery_kind" = computer-use-helper ]; then python3 - \ "$recovery_state" \ "$recovery_log" \ "$CHANNEL_APP_PATH" \ "$CHANNEL_ENTITLEMENTS" \ "$CHANNEL" \ "$NIGHTLY_VARIANT" \ "$CHANNEL_RELEASE_TAG" \ "$NIGHTLY_BUILD" \ "${{ needs.decide.outputs.head_sha }}" \ "${{ needs.decide.outputs.short_sha }}" <<'PY' import json import hashlib import pathlib import sys state_path, log_path, app_path, entitlements_path, channel, variant, release_tag, build, head_sha, short_sha = sys.argv[1:] state = {} for line in pathlib.Path(state_path).read_text(encoding="utf-8").splitlines(): if not line: continue key, sep, value = line.partition("=") if not sep or key in state: raise SystemExit(f"invalid helper notarization state line: {line!r}") state[key] = value submission_id = state.get("submission_id", "") cdhashes = state.get("cdhashes", "") if not submission_id or not all(character.isalnum() or character in "._-" for character in submission_id): raise SystemExit("helper state has no valid Apple submission id") if not cdhashes or any("=" not in value or len(value.split("=", 1)[1]) != 40 or not all(character in "0123456789abcdefABCDEF" for character in value.split("=", 1)[1]) for value in cdhashes.split(",")): raise SystemExit("helper state has invalid CDHash evidence") if state.get("wait_exit", "0") == "0" and state.get("status") == "Accepted" and state.get("post_wait_pending") != "true": raise SystemExit("accepted helper must not be uploaded as pending recovery") pathlib.Path("cmux-nightly-notarization-recovery.json").write_text( json.dumps({ "schema": 2, "recovery_kind": "computer-use-helper", "source_run_id": "${{ github.run_id }}", "source_run_attempt": "${{ github.run_attempt }}", "should_publish": "${{ needs.decide.outputs.should_publish }}" == "true", "channel": channel, "variant": variant, "release_tag": release_tag, "build": build, "head_sha": head_sha, "short_sha": short_sha, "dmg_path": f"cmux-{channel}-macos-{variant}.dmg", "app_path": f"cmux-nightly-notarization-recovery-app/{pathlib.Path(app_path).name}", "app_archive_path": "cmux-nightly-notarization-recovery-app.tar.gz", "app_entitlements_path": pathlib.Path(entitlements_path).name, "app_entitlements_sha256": hashlib.sha256(pathlib.Path(entitlements_path).read_bytes()).hexdigest(), "helper_state_path": pathlib.Path(state_path).name, "helper_log_path": pathlib.Path(log_path).name, "immutable_path": f"cmux-{channel}-macos-{variant}-{build}.dmg", "dmg_prefix": f"cmux-{channel}-macos", "submission_id": submission_id, "helper_cdhashes": cdhashes, }, indent=2) + "\n", encoding="utf-8", ) PY else dmg_sha256="$(shasum -a 256 "$NIGHTLY_DMG_RELEASE" | awk '{print $1}')" [[ "$dmg_sha256" =~ ^[[:xdigit:]]{64}$ ]] || { echo "Could not record a SHA-256 for $NIGHTLY_DMG_RELEASE" >&2 exit 1 } python3 - \ "$recovery_state" \ "$dmg_sha256" \ "$NIGHTLY_DMG_RELEASE" \ "$CHANNEL_APP_PATH" \ "$CHANNEL" \ "$NIGHTLY_VARIANT" \ "$CHANNEL_RELEASE_TAG" \ "$NIGHTLY_BUILD" \ "${{ needs.decide.outputs.head_sha }}" \ "${{ needs.decide.outputs.short_sha }}" <<'PY' import json import pathlib import sys state_path, actual_sha, dmg_path, app_path, channel, variant, release_tag, build, head_sha, short_sha = sys.argv[1:] state = {} for line in pathlib.Path(state_path).read_text(encoding="utf-8").splitlines(): if not line: continue key, sep, value = line.partition("=") if not sep or key in state: raise SystemExit(f"invalid notarization state line: {line!r}") state[key] = value if state.get("dmg_sha256") != actual_sha: raise SystemExit("notarization state SHA-256 does not match the signed DMG") if state.get("submit_exit", "0") != "0": raise SystemExit("notarization submit exited nonzero; refusing automatic continuation") submission_id = state.get("submission_id", "") if not submission_id or not all(character.isalnum() or character in "._-" for character in submission_id): raise SystemExit("notarization state has no valid Apple submission id") for key in ("immutable_path", "release_tag", "dmg_prefix", "variant", "channel"): if not state.get(key): raise SystemExit(f"notarization state is missing {key}") if state["channel"] != channel or state["variant"] != variant or state["release_tag"] != release_tag: raise SystemExit("notarization state metadata does not match the build") if state.get("dmg_path", "").split("/")[-1] != pathlib.Path(dmg_path).name: raise SystemExit("notarization state points at a different DMG") pathlib.Path("cmux-nightly-notarization-recovery.json").write_text( json.dumps({ "schema": 1, "source_run_id": "${{ github.run_id }}", "source_run_attempt": "${{ github.run_attempt }}", "should_publish": "${{ needs.decide.outputs.should_publish }}" == "true", "channel": channel, "variant": variant, "release_tag": release_tag, "build": build, "head_sha": head_sha, "short_sha": short_sha, "dmg_path": pathlib.Path(dmg_path).name, "app_path": f"cmux-nightly-notarization-recovery-app/{pathlib.Path(app_path).name}", "app_archive_path": "cmux-nightly-notarization-recovery-app.tar.gz", "state_path": pathlib.Path(state_path).name, "log_path": f"{pathlib.Path(dmg_path).name}.notarization.log", "immutable_path": state.get("immutable_path", ""), "dmg_prefix": state.get("dmg_prefix", ""), "dmg_sha256": actual_sha, "submission_id": submission_id, }, indent=2) + "\n", encoding="utf-8", ) PY fi cp "$recovery_state" "$metadata_staging/" cp cmux-nightly-notarization-recovery.json "$metadata_staging/" if [ "$recovery_kind" = computer-use-helper ]; then cp "$recovery_state" "$recovery_staging/cmux-computer-use-notarization.state" cp "$recovery_log" "$recovery_staging/cmux-computer-use-notarization.state.log" cp "$CHANNEL_ENTITLEMENTS" "$recovery_staging/$(basename "$CHANNEL_ENTITLEMENTS")" else cp "$NIGHTLY_DMG_RELEASE" "$recovery_staging/$(basename "$NIGHTLY_DMG_RELEASE")" cp "$recovery_state" "$recovery_staging/$(basename "$recovery_state")" cp "$recovery_log" "$recovery_staging/$(basename "$recovery_log")" fi cp "$recovery_app_archive" "$recovery_staging/" cp cmux-nightly-notarization-recovery.json "$recovery_staging/" - name: Upload pending notarization recovery artifact if: always() && steps.prepare-notarization-recovery.outcome == 'success' && steps.prepare-notarization-recovery.outputs.kind == 'dmg' uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: cmux-${{ needs.decide.outputs.channel }}-notarization-recovery-${{ matrix.variant }}-${{ needs.decide.outputs.short_sha }} path: cmux-nightly-notarization-recovery-staging/* compression-level: 0 if-no-files-found: error retention-days: 14 - name: Upload helper notarization recovery artifact if: always() && steps.prepare-notarization-recovery.outcome == 'success' && steps.prepare-notarization-recovery.outputs.kind == 'computer-use-helper' uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: cmux-${{ needs.decide.outputs.channel }}-notarization-recovery-${{ matrix.variant }}-${{ needs.decide.outputs.short_sha }} path: cmux-nightly-notarization-recovery-staging/* compression-level: 0 if-no-files-found: error retention-days: 14 - name: Upload notarization polling metadata if: always() && steps.prepare-notarization-recovery.outcome == 'success' && steps.prepare-notarization-recovery.outputs.kind == 'dmg' uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: cmux-${{ needs.decide.outputs.channel }}-notarization-metadata-${{ matrix.variant }}-${{ needs.decide.outputs.short_sha }} path: cmux-nightly-notarization-metadata-staging/* if-no-files-found: error retention-days: 14 - name: Upload helper polling metadata if: always() && steps.prepare-notarization-recovery.outcome == 'success' && steps.prepare-notarization-recovery.outputs.kind == 'computer-use-helper' uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: cmux-${{ needs.decide.outputs.channel }}-notarization-metadata-${{ matrix.variant }}-${{ needs.decide.outputs.short_sha }} path: cmux-nightly-notarization-metadata-staging/* if-no-files-found: error retention-days: 14 - name: Gate distribution with syspolicy_check if: needs.decide.outputs.fast_build != 'true' && needs.decide.outputs.should_publish != 'true' run: syspolicy_check distribution "$CHANNEL_APP_PATH" - name: Generate Sparkle appcasts (nightly) if: needs.decide.outputs.fast_build != 'true' && needs.decide.outputs.should_publish != 'true' env: NIGHTLY_SPARKLE_KEY: ${{ vars.NIGHTLY_SPARKLE_KEY }} CHANNEL: ${{ needs.decide.outputs.channel }} SHARED_SPARKLE_PRIVATE_KEY: ${{ secrets.SPARKLE_PRIVATE_KEY }} NIGHTLY_SPARKLE_PRIVATE_KEY: ${{ secrets.NIGHTLY_SPARKLE_PRIVATE_KEY }} run: | set -euo pipefail SPARKLE_PRIVATE_KEY="$(./scripts/ci/nightly-sparkle-key.sh)" export SPARKLE_PRIVATE_KEY SPARKLE_DELTA_NAME_PREFIX="${CHANNEL_DMG_PREFIX}-${NIGHTLY_VARIANT}-" \ ./scripts/sparkle_generate_appcast.sh "$NIGHTLY_DMG_IMMUTABLE" "$CHANNEL_RELEASE_TAG" "$NIGHTLY_APPCAST" ls -l "$NIGHTLY_DMG_IMMUTABLE" "$NIGHTLY_DMG_RELEASE" "$NIGHTLY_APPCAST" if grep -q 'sparkle:deltas' "$NIGHTLY_APPCAST"; then echo "ERROR: first publication appcasts must contain full archives only" >&2 exit 1 fi - name: Upload nightly variant artifacts if: needs.decide.outputs.fast_build != 'true' && needs.decide.outputs.should_publish != 'true' uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: cmux-nightly-variant-${{ matrix.variant }} path: | ${{ needs.decide.outputs.dmg_prefix }}-*.dmg ${{ needs.decide.outputs.dmg_prefix }}-*.delta appcast*.xml if-no-files-found: error retention-days: 3 - name: Upload fast arm64 dogfood artifact if: needs.decide.outputs.fast_build == 'true' uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: cmux-nightly-fast-${{ needs.decide.outputs.short_sha }} path: | ${{ needs.decide.outputs.dmg_prefix }}-arm64.dmg ${{ needs.decide.outputs.dmg_prefix }}-arm64-*.dmg remote-daemon-assets/cmuxd-remote-* if-no-files-found: error retention-days: 3 - name: Cleanup keychain if: always() run: | ./scripts/ci/signing-keychain.sh delete --keychain build.keychain \ || security delete-keychain build.keychain >/dev/null 2>&1 || true rm -f /tmp/cert.p12 verify-signed-nightly: name: Verify signed nightly app (${{ matrix.variant }}) # No environment and no secrets: this job runs the signed app, its CLI, # helpers and sidecar. It runs even when signing failed after the app # was signed (for example a queued helper), because the notarization # continuation publishes only variants whose verify job passed. needs: [decide, build-nightly-app, build-sign-notarize-nightly] if: always() && needs.build-sign-notarize-nightly.result != 'skipped' && needs.build-sign-notarize-nightly.result != 'cancelled' permissions: contents: read runs-on: macos-26 timeout-minutes: 30 strategy: fail-fast: false matrix: variant: ${{ fromJSON(needs.decide.outputs.variants) }} env: CMUX_CI_XCODE_APP: ${{ vars.CMUX_CI_XCODE_APP_MACOS_26 }} CMUX_CI_REQUIRED_MACOS_SDK_MAJOR: "26" NIGHTLY_VARIANT: ${{ matrix.variant }} NIGHTLY_BUILD: ${{ needs.build-nightly-app.outputs.daemon_build }} BUNDLE_REMOTE_DAEMON_ASSETS: ${{ needs.decide.outputs.fast_build == 'true' || needs.decide.outputs.should_publish != 'true' }} CHANNEL: ${{ needs.decide.outputs.channel }} CHANNEL_DMG_PREFIX: ${{ needs.decide.outputs.dmg_prefix }} CHANNEL_APP_PATH: signed/${{ needs.decide.outputs.app_name }}.app steps: - name: Checkout build ref uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: persist-credentials: false ref: ${{ needs.decide.outputs.head_sha }} submodules: false # nightly-next's license check reads ghostty-next/build.zig.zon. - name: Initialize cmux-next notices submodule if: github.ref_name == 'nightly-next' shell: bash run: bash scripts/ci/init-submodule.sh ghostty-next - name: Select Xcode run: ./scripts/select-ci-xcode.sh - name: Download signed app uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0 with: name: cmux-${{ needs.decide.outputs.channel }}-signed-app-${{ matrix.variant }}-${{ needs.decide.outputs.short_sha }} path: signed-app-handoff - name: Download SSH daemon assets uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0 with: name: cmux-nightly-remote-daemon path: remote-daemon-assets - name: Restore signed app run: | set -euo pipefail (cd signed-app-handoff && shasum -a 256 -c cmux-signed-app.tar.gz.sha256) mkdir -p signed tar -C signed -xzf signed-app-handoff/cmux-signed-app.tar.gz test -d "$CHANNEL_APP_PATH" /usr/bin/codesign --verify --deep --strict --verbose=2 "$CHANNEL_APP_PATH" - name: Verify signed diff sidecar runtime contract run: | set -euo pipefail ./scripts/verify-diff-sidecar-artifact.sh \ "$CHANNEL_APP_PATH/Contents/Resources/bin/cmux-diff-sidecar" \ --archs "$(lipo -archs "$CHANNEL_APP_PATH/Contents/MacOS/cmux")" \ --require-signed - name: Verify signed app SSH daemon contract run: | bundle_args=() if [[ "$BUNDLE_REMOTE_DAEMON_ASSETS" == "true" ]]; then bundle_args+=(--require-bundled-assets) fi python3 scripts/verify_remote_daemon_release.py \ --manifest "remote-daemon-assets/cmuxd-remote-manifest-${NIGHTLY_BUILD}.json" \ --assets-dir remote-daemon-assets \ --app "$CHANNEL_APP_PATH" --verify-cli "${bundle_args[@]}" - name: Smoke launch signed app run: | set -euo pipefail CMUX_SMOKE_DIRECT_EXEC=1 CMUX_SMOKE_DEBUG_LOGS=1 \ ./scripts/smoke-launch-macos-app.sh \ "$CHANNEL_APP_PATH" - name: Smoke bundled CLI against the signed app run: | set -euo pipefail # Launch-only smoke proves the process lives; this drives the shipped # app through the CLI inside the same bundle (version, help, SSH # daemon manifest, ping, capabilities, workspace create/list/close). # A thin x86_64 variant skips when the runner has no Rosetta. ./scripts/smoke-signed-app-cli.sh \ "$CHANNEL_APP_PATH" # The inline path notarized and stapled the final DMG in the signing job # with its launch smokes deferred. Smoke the app inside that exact DMG # and record what was tested; publish-nightly publishes only these bytes. - name: Download final DMG if: needs.decide.outputs.fast_build != 'true' && needs.decide.outputs.should_publish != 'true' uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0 with: name: cmux-nightly-variant-${{ matrix.variant }} path: final-dmg - name: Smoke final DMG if: needs.decide.outputs.fast_build != 'true' && needs.decide.outputs.should_publish != 'true' run: | set -euo pipefail mkdir -p tested : > "tested/tested-${NIGHTLY_VARIANT}.sha256" release="final-dmg/${CHANNEL_DMG_PREFIX}-${NIGHTLY_VARIANT}.dmg" [ -f "$release" ] || { echo "no final DMG for $NIGHTLY_VARIANT" >&2; exit 1; } ./scripts/ci/smoke-dmg.sh "$release" "$CHANNEL" 3>"$RUNNER_TEMP/tested.line" tested="$(awk '$1 == "tested" {print $2}' "$RUNNER_TEMP/tested.line")" [ -n "$tested" ] || { echo "smoke-dmg.sh reported no tested digest" >&2; exit 1; } # The immutable DMG is a copy of the release DMG: require the same bytes. for dmg in final-dmg/"${CHANNEL_DMG_PREFIX}"-*.dmg; do [ -f "$dmg" ] || continue actual="$(shasum -a 256 "$dmg" | awk '{print $1}')" [ "$actual" = "$tested" ] || { echo "$(basename "$dmg") differs from the DMG that was launched" >&2; exit 1; } printf '%s %s\n' "$actual" "$(basename "$dmg")" >> "tested/tested-${NIGHTLY_VARIANT}.sha256" done cat "tested/tested-${NIGHTLY_VARIANT}.sha256" - name: Upload tested DMG digests if: needs.decide.outputs.fast_build != 'true' && needs.decide.outputs.should_publish != 'true' uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: cmux-nightly-tested-${{ matrix.variant }} path: tested/tested-${{ matrix.variant }}.sha256 if-no-files-found: error retention-days: 3 publish-nightly: # Production secrets: GitHub releases them only to protected refs (the # environment's deployment branch policy: main, tags v*). environment: release permissions: contents: write pull-requests: read attestations: write id-token: write # rc/** branches share one release. Serialize its mutable aliases and feeds # by channel, even when workflow-level groups have different branch names. concurrency: group: release-publish-${{ needs.decide.outputs.release_tag }} cancel-in-progress: false needs: [decide, build-nightly-app, build-sign-notarize-nightly, verify-signed-nightly] outputs: tag_status: ${{ steps.tag-outcome.outputs.tag_status }} # Published paths are completed by auto-resume-nightly-notarization after # Apple accepts every exact DMG. This job remains only for private branch # artifacts, where no Apple ticket or release publication is involved. if: needs.decide.outputs.should_build == 'true' && needs.decide.outputs.fast_build != 'true' && needs.decide.outputs.build_only != 'true' && needs.decide.outputs.should_publish != 'true' && (github.event_name != 'schedule' || github.event.schedule == '47 8 * * *') runs-on: ${{ github.repository_owner != 'manaflow-ai' && 'ubuntu-24.04' || 'blacksmith-2vcpu-ubuntu-2404' }} timeout-minutes: 30 env: NIGHTLY_LEGACY_VARIANT: ${{ needs.decide.outputs.legacy_variant }} NIGHTLY_BUILD: ${{ needs.build-nightly-app.outputs.daemon_build }} CHANNEL: ${{ needs.decide.outputs.channel }} CHANNEL_DMG_PREFIX: ${{ needs.decide.outputs.dmg_prefix }} CHANNEL_RELEASE_TAG: ${{ needs.decide.outputs.release_tag }} CHANNEL_FEED_BASE: ${{ needs.decide.outputs.feed_base }} steps: - name: Checkout publish ref uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: persist-credentials: false ref: ${{ needs.decide.outputs.head_sha }} # The wrapper comes from the workflow commit: the build checkout may predate it. - name: Checkout the gh token wrapper at the workflow commit uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: ref: ${{ github.sha }} fetch-depth: 1 persist-credentials: false path: .gh-tooling sparse-checkout: | scripts/ci/gh_app_token.sh scripts/ci/publish-release-assets.py scripts/ci/finalize-release.py scripts/prune_nightly_release_assets.py sparse-checkout-cone-mode: false # Release App first, github.token as the fallback (scripts/ci/gh_app_token.sh): the App is # its own actor, so a limit that every workflow's github.token shares does not refuse it. - name: Mint the release App token for this job's gh calls id: gh-app-token continue-on-error: true timeout-minutes: 2 uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 with: app-id: ${{ secrets.CMUX_RELEASE_APP_ID }} private-key: ${{ secrets.CMUX_RELEASE_APP_KEY }} owner: ${{ github.repository_owner }} repositories: ${{ github.event.repository.name }} permission-contents: write - name: Send this job's gh calls as the release App, github.token as the fallback env: GH_APP_TOKEN: ${{ steps.gh-app-token.outputs.token }} GH_FALLBACK_TOKEN: ${{ github.token }} # This job's timeout leaves room for 10 minutes of backoff (gh_app_token.sh reads the # cap from GH_BUDGET_WAIT_MAX_SECONDS). run: | if [ -f .gh-tooling/scripts/ci/gh_app_token.sh ]; then bash .gh-tooling/scripts/ci/gh_app_token.sh --install echo "GH_BUDGET_WAIT_MAX_SECONDS=600" >> "$GITHUB_ENV" fi - name: Download nightly variant artifacts uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 with: pattern: cmux-nightly-variant-* path: nightly-out merge-multiple: true # Publish only the bytes that verify-signed-nightly launched. - name: Download tested DMG digests uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 with: pattern: cmux-nightly-tested-* path: tested merge-multiple: true - name: Require every DMG to match a tested digest run: | set -euo pipefail cat tested/*.sha256 > "$RUNNER_TEMP/tested.sha256" count=0 for dmg in nightly-out/*.dmg; do [ -f "$dmg" ] || continue count=$((count + 1)) actual="$(shasum -a 256 "$dmg" | awk '{print $1}')" if ! grep -Fxq "$actual $(basename "$dmg")" "$RUNNER_TEMP/tested.sha256"; then echo "$(basename "$dmg") ($actual) is not a DMG that verify-signed-nightly tested" >&2 exit 1 fi done [ "$count" -gt 0 ] || { echo "no DMG to publish" >&2; exit 1; } echo "All $count DMGs match the digests verify-signed-nightly tested." - name: Download SSH daemon assets for publication uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0 with: name: cmux-nightly-remote-daemon path: nightly-out - name: Verify SSH daemon assets survived artifact transfer run: | python3 scripts/verify_remote_daemon_release.py \ --manifest "nightly-out/cmuxd-remote-manifest-${NIGHTLY_BUILD}.json" \ --assets-dir nightly-out - name: Assemble legacy nightly names run: | set -euo pipefail cd nightly-out ls -l for variant in arm64 x86_64; do [ -f "${CHANNEL_DMG_PREFIX}-${variant}.dmg" ] || { echo "missing ${CHANNEL_DMG_PREFIX}-${variant}.dmg" >&2; exit 1; } [ -f "appcast-${variant}.xml" ] || { echo "missing appcast-${variant}.xml" >&2; exit 1; } done # `cmux-nightly-macos.dmg` and `appcast.xml` are the download and feed # every pre-existing link and install uses. They stay universal: the # universal variant generates appcast.xml itself, and installs move to # their architecture's feed on the next update check. [ "$NIGHTLY_LEGACY_VARIANT" = "universal" ] || { echo "unexpected legacy variant: $NIGHTLY_LEGACY_VARIANT" >&2; exit 1; } [ -f "${CHANNEL_DMG_PREFIX}-universal.dmg" ] || { echo "universal variant DMG missing" >&2; exit 1; } [ -f appcast.xml ] || { echo "universal variant appcast.xml missing" >&2; exit 1; } cp "${CHANNEL_DMG_PREFIX}-universal.dmg" "${CHANNEL_DMG_PREFIX}.dmg" # Compatibility feed for nightlies older than the unified appcast. cp appcast.xml appcast-universal.xml ls -l - name: Prepare nightly release notes and appcast summaries if: needs.decide.outputs.should_publish == 'true' env: GH_TOKEN: ${{ github.token }} NOTES_HEAD: ${{ needs.decide.outputs.head_sha }} NOTES_BRANCH: ${{ github.ref_name }} NOTES_APP_NAME: ${{ needs.decide.outputs.app_name }} NOTES_BUNDLE_ID: ${{ needs.decide.outputs.bundle_id }} run: | set -euo pipefail cat > nightly-downloads.md <> "$GITHUB_OUTPUT" { echo "### Channel release tag updated" echo "" echo "\`$CHANNEL_RELEASE_TAG\` now points to \`$HEAD_SHA\`." } >> "$GITHUB_STEP_SUMMARY" else echo "tag_status=stale" >> "$GITHUB_OUTPUT" echo "::warning::Published assets, release metadata, and appcasts are live; the $CHANNEL_RELEASE_TAG tag marker is stale because its final update failed." { echo "### Published, tag marker stale" echo "" echo "Assets, release metadata, and appcasts for \`$HEAD_SHA\` are published, but \`$CHANNEL_RELEASE_TAG\` was not advanced. The next eligible publication will retry the marker without republishing this commit." } >> "$GITHUB_STEP_SUMMARY" fi generate-nightly-deltas: # Delta work is deliberately downstream of the first publication. A slow # BinaryDelta process therefore cannot extend the user's wait for a full # DMG and signed appcast. environment: release needs: [decide, build-nightly-app, publish-nightly] if: always() && needs.decide.outputs.should_publish == 'true' && needs.decide.outputs.fast_build != 'true' && needs.publish-nightly.result == 'success' permissions: contents: read actions: read runs-on: macos-26 strategy: fail-fast: false matrix: variant: ${{ fromJSON(needs.decide.outputs.variants) }} timeout-minutes: 45 env: NIGHTLY_VARIANT: ${{ matrix.variant }} NIGHTLY_BUILD: ${{ needs.build-nightly-app.outputs.daemon_build }} CHANNEL: ${{ needs.decide.outputs.channel }} CHANNEL_DMG_PREFIX: ${{ needs.decide.outputs.dmg_prefix }} CHANNEL_RELEASE_TAG: ${{ needs.decide.outputs.release_tag }} NIGHTLY_SPARKLE_KEY: ${{ vars.NIGHTLY_SPARKLE_KEY }} SHARED_SPARKLE_PRIVATE_KEY: ${{ secrets.SPARKLE_PRIVATE_KEY }} NIGHTLY_SPARKLE_PRIVATE_KEY: ${{ secrets.NIGHTLY_SPARKLE_PRIVATE_KEY }} steps: - name: Checkout build ref uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: persist-credentials: false ref: ${{ needs.decide.outputs.head_sha }} # The wrapper comes from the workflow commit: the build checkout may predate it. - name: Checkout the gh token wrapper at the workflow commit uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: ref: ${{ github.sha }} fetch-depth: 1 persist-credentials: false path: .gh-tooling sparse-checkout: scripts/ci/gh_app_token.sh sparse-checkout-cone-mode: false # Release App first, github.token as the fallback (scripts/ci/gh_app_token.sh): the App is # its own actor, so a limit that every workflow's github.token shares does not refuse it. - name: Mint the release App token for this job's gh calls id: gh-app-token continue-on-error: true timeout-minutes: 2 uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 with: app-id: ${{ secrets.CMUX_RELEASE_APP_ID }} private-key: ${{ secrets.CMUX_RELEASE_APP_KEY }} owner: ${{ github.repository_owner }} repositories: ${{ github.event.repository.name }} permission-contents: read - name: Send this job's gh calls as the release App, github.token as the fallback env: GH_APP_TOKEN: ${{ steps.gh-app-token.outputs.token }} GH_FALLBACK_TOKEN: ${{ github.token }} # This job's timeout leaves room for 10 minutes of backoff (gh_app_token.sh reads the # cap from GH_BUDGET_WAIT_MAX_SECONDS). run: | if [ -f .gh-tooling/scripts/ci/gh_app_token.sh ]; then bash .gh-tooling/scripts/ci/gh_app_token.sh --install echo "GH_BUDGET_WAIT_MAX_SECONDS=600" >> "$GITHUB_ENV" fi - name: Select Xcode run: ./scripts/select-ci-xcode.sh - name: Download published variant uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 with: name: cmux-nightly-variant-${{ matrix.variant }} path: nightly-delta/input - name: Fetch one previous track archive env: GH_TOKEN: ${{ github.token }} run: | set -euo pipefail python3 scripts/ci/fetch-previous-nightly-dmgs.py \ --repo manaflow-ai/cmux \ --release-tag "$CHANNEL_RELEASE_TAG" \ --name-prefix "${CHANNEL_DMG_PREFIX}-" \ --variant "$NIGHTLY_VARIANT" \ --exclude-build "$NIGHTLY_BUILD" \ --count 1 \ --out nightly-delta/previous - name: Generate one from-version delta and revised appcast env: GH_TOKEN: ${{ github.token }} run: | set -euo pipefail mkdir -p nightly-delta/output dmg="nightly-delta/input/${CHANNEL_DMG_PREFIX}-${NIGHTLY_VARIANT}-${NIGHTLY_BUILD}.dmg" feed="appcast.xml" if [ "$NIGHTLY_VARIANT" != "universal" ]; then feed="appcast-${NIGHTLY_VARIANT}.xml" fi test -s "$dmg" if ! compgen -G 'nightly-delta/previous/*.dmg' >/dev/null; then echo "No previous $NIGHTLY_VARIANT archive; retaining the full-only appcast" cp "nightly-delta/input/$feed" "nightly-delta/output/$feed" exit 0 fi SPARKLE_PRIVATE_KEY="$(./scripts/ci/nightly-sparkle-key.sh)" export SPARKLE_PRIVATE_KEY SPARKLE_PUBLIC_KEY="$( printf '%s' "$SPARKLE_PRIVATE_KEY" | env -u SPARKLE_PRIVATE_KEY \ -u SHARED_SPARKLE_PRIVATE_KEY \ -u NIGHTLY_SPARKLE_PRIVATE_KEY \ swift scripts/derive_sparkle_public_key.swift --stdin )" export SPARKLE_PUBLIC_KEY ./scripts/ci/drop-previous-nightlies-with-other-sparkle-key.sh \ nightly-delta/previous "$SPARKLE_PUBLIC_KEY" SPARKLE_PREVIOUS_ARCHIVES_DIR=nightly-delta/previous \ SPARKLE_MAXIMUM_DELTAS=1 \ SPARKLE_DELTA_NAME_PREFIX="${CHANNEL_DMG_PREFIX}-${NIGHTLY_VARIANT}-" \ ./scripts/sparkle_generate_appcast.sh \ "$dmg" "$CHANNEL_RELEASE_TAG" "nightly-delta/output/$feed" ls -l nightly-delta/output - name: Upload delta result uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: cmux-nightly-deltas-${{ matrix.variant }} path: nightly-delta/output/* if-no-files-found: error retention-days: 3 republish-nightly-deltas: # This job is intentionally outside publish-nightly's concurrency group: # a newer run may publish its full feed immediately, and the tag guard below # prevents an older delta from overwriting it. needs: [decide, build-nightly-app, publish-nightly, generate-nightly-deltas] if: always() && needs.decide.outputs.should_publish == 'true' && needs.decide.outputs.fast_build != 'true' && needs.publish-nightly.result == 'success' environment: release runs-on: ${{ github.repository_owner != 'manaflow-ai' && 'ubuntu-24.04' || 'blacksmith-2vcpu-ubuntu-2404' }} permissions: contents: write actions: read env: CHANNEL_RELEASE_TAG: ${{ needs.decide.outputs.release_tag }} CHANNEL_FEED_BASE: ${{ needs.decide.outputs.feed_base }} NIGHTLY_BUILD: ${{ needs.build-nightly-app.outputs.daemon_build }} CHANNEL_DMG_PREFIX: ${{ needs.decide.outputs.dmg_prefix }} steps: - name: Checkout build ref uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: persist-credentials: false ref: ${{ needs.decide.outputs.head_sha }} # The wrapper comes from the workflow commit: the build checkout may predate it. - name: Checkout the gh token wrapper at the workflow commit uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: ref: ${{ github.sha }} fetch-depth: 1 persist-credentials: false path: .gh-tooling sparse-checkout: | scripts/ci/gh_app_token.sh scripts/ci/publish-release-assets.py scripts/ci/finalize-release.py scripts/prune_nightly_release_assets.py sparse-checkout-cone-mode: false # Release App first, github.token as the fallback (scripts/ci/gh_app_token.sh): the App is # its own actor, so a limit that every workflow's github.token shares does not refuse it. - name: Mint the release App token for this job's gh calls id: gh-app-token continue-on-error: true timeout-minutes: 2 uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 with: app-id: ${{ secrets.CMUX_RELEASE_APP_ID }} private-key: ${{ secrets.CMUX_RELEASE_APP_KEY }} owner: ${{ github.repository_owner }} repositories: ${{ github.event.repository.name }} permission-contents: write - name: Send this job's gh calls as the release App, github.token as the fallback env: GH_APP_TOKEN: ${{ steps.gh-app-token.outputs.token }} GH_FALLBACK_TOKEN: ${{ github.token }} # This job's timeout leaves room for 10 minutes of backoff (gh_app_token.sh reads the # cap from GH_BUDGET_WAIT_MAX_SECONDS). run: | if [ -f .gh-tooling/scripts/ci/gh_app_token.sh ]; then bash .gh-tooling/scripts/ci/gh_app_token.sh --install echo "GH_BUDGET_WAIT_MAX_SECONDS=600" >> "$GITHUB_ENV" fi - name: Download delta results uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 with: pattern: cmux-nightly-deltas-* path: nightly-deltas merge-multiple: true - name: Republish deltas and revised appcasts id: republish env: GH_TOKEN: ${{ github.token }} GH_APP_TOKEN: ${{ steps.gh-app-token.outputs.token }} # the uploader: App first, GH_TOKEN as fallback run: | set -euo pipefail current_sha="$(gh api "repos/$GITHUB_REPOSITORY/commits/$CHANNEL_RELEASE_TAG" --jq .sha)" if [ "$current_sha" != "${{ needs.decide.outputs.head_sha }}" ]; then echo "Skipping stale delta result: $CHANNEL_RELEASE_TAG is at $current_sha" echo "publish=false" >> "$GITHUB_OUTPUT" exit 0 fi deltas=() for delta in nightly-deltas/*.delta; do [ -f "$delta" ] || continue deltas+=(--immutable "$delta") done if [ "${#deltas[@]}" -eq 0 ]; then echo "No Sparkle deltas were generated; full appcasts remain current" echo "publish=false" >> "$GITHUB_OUTPUT" exit 0 fi feeds=() for feed in nightly-deltas/appcast*.xml; do [ -f "$feed" ] || continue feeds+=(--feed "$feed") done [ "${#feeds[@]}" -gt 0 ] python3 .gh-tooling/scripts/ci/publish-release-assets.py \ --repo "$GITHUB_REPOSITORY" --tag "$CHANNEL_RELEASE_TAG" \ "${deltas[@]}" --replace-feeds "${feeds[@]}" echo "publish=true" >> "$GITHUB_OUTPUT" - name: Upload revised appcasts to R2 if: steps.republish.outputs.publish == 'true' env: AWS_ACCESS_KEY_ID: ${{ secrets.CF_R2_ACCESS_KEY_ID }} AWS_SECRET_ACCESS_KEY: ${{ secrets.CF_R2_SECRET_ACCESS_KEY }} AWS_DEFAULT_REGION: auto R2_ENDPOINT: "https://${{ secrets.CF_R2_ACCOUNT_ID }}.r2.cloudflarestorage.com" run: | set -euo pipefail for feed in nightly-deltas/appcast*.xml; do [ -f "$feed" ] || continue name="$(basename "$feed")" python3 scripts/ci/upload-r2-object.py \ --file "$feed" \ --endpoint-url "$R2_ENDPOINT" \ --bucket cmux-binaries \ --key "${CHANNEL_RELEASE_TAG}/$name" \ --content-type "application/xml" \ --cache-control "no-cache, no-store, must-revalidate" done # Keep one open issue per failed publication boundary. A stale channel tag # leaves the assets, release metadata, and feeds available, so the incident # must say that explicitly instead of claiming installs stopped updating. report-nightly-failure: needs: [decide, build-nightly-ghostty-cli-helper, build-nightly-app, resolve-nightly-cmux-tui-client, prepare-nightly-variant, build-sign-notarize-nightly, verify-signed-nightly, publish-nightly, generate-nightly-deltas, republish-nightly-deltas] if: always() && needs.decide.outputs.should_publish == 'true' && (contains(needs.*.result, 'failure') || needs.publish-nightly.outputs.tag_status == 'stale') # Push runs build in parallel, so two failed runs can report at once: one # reporter at a time keeps the "find or open the one issue" step from # opening two. It builds and publishes nothing; a replaced pending report # is an older failure the newer report covers. A close never takes an # incident a newer commit reported (scripts/ci/nightly_failure_issue.mjs). concurrency: group: nightly-failure-report-${{ needs.decide.outputs.release_tag }} cancel-in-progress: false runs-on: ${{ github.repository_owner != 'manaflow-ai' && 'ubuntu-24.04' || 'blacksmith-2vcpu-ubuntu-2404' }} permissions: contents: read issues: write steps: - name: Open or update the nightly failure issue uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 env: HEAD_SHA: ${{ needs.decide.outputs.head_sha }} RESULTS: ${{ toJSON(needs) }} CHANNEL: ${{ needs.decide.outputs.channel }} CHANNEL_APP_NAME: ${{ needs.decide.outputs.app_name }} REF_NAME: ${{ github.ref_name }} TAG_STATUS: ${{ needs.publish-nightly.outputs.tag_status }} with: script: | const { owner, repo } = context.repo; const channel = process.env.CHANNEL; const appName = process.env.CHANNEL_APP_NAME; const refName = process.env.REF_NAME; const label = `${channel}-failure`; const runUrl = `${context.serverUrl}/${owner}/${repo}/actions/runs/${context.runId}`; const results = JSON.parse(process.env.RESULTS); const failed = Object.entries(results).filter(([, v]) => v.result === 'failure').map(([k]) => k); const tagStale = process.env.TAG_STATUS === 'stale'; const publicationLine = tagStale ? `${appName} published, tag marker stale for ${process.env.HEAD_SHA} (${refName}). Assets, release metadata, and appcasts are available; only the channel completion marker failed to advance.` : `${appName} run ${runUrl} failed on ${process.env.HEAD_SHA} (${refName}) in: ${failed.join(', ')}.`; const failedLine = failed.length > 0 ? `Failed jobs: ${failed.join(', ')}.` : 'The tag marker update failed after publication completed.'; const body = `${publicationLine}\n\n${failedLine}\n\nThe next successful publication will update this incident. Run: ${runUrl}`; try { await github.rest.issues.getLabel({ owner, repo, name: label }); } catch (error) { if (error.status !== 404) throw error; await github.rest.issues.createLabel({ owner, repo, name: label, color: 'b60205', description: `${appName} publish failing` }); } const open = await github.rest.issues.listForRepo({ owner, repo, labels: label, state: 'open', per_page: 1 }); if (open.data.length > 0) { await github.rest.issues.createComment({ owner, repo, issue_number: open.data[0].number, body }); } else { await github.rest.issues.create({ owner, repo, title: `${appName} build is failing on ${refName}`, labels: [label], body }); } close-nightly-failure-issue: needs: [decide, publish-nightly, generate-nightly-deltas, republish-nightly-deltas] if: needs.decide.outputs.should_publish == 'true' && needs.publish-nightly.result == 'success' && needs.publish-nightly.outputs.tag_status == 'updated' && (needs.generate-nightly-deltas.result == 'success' || needs.generate-nightly-deltas.result == 'skipped') && (needs.republish-nightly-deltas.result == 'success' || needs.republish-nightly-deltas.result == 'skipped') runs-on: ${{ github.repository_owner != 'manaflow-ai' && 'ubuntu-24.04' || 'blacksmith-2vcpu-ubuntu-2404' }} permissions: contents: read issues: write steps: - name: Checkout the failure-issue helper uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: ref: ${{ github.sha }} persist-credentials: false sparse-checkout: scripts/ci/nightly_failure_issue.mjs sparse-checkout-cone-mode: false - name: Close the nightly failure issue uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 env: HEAD_SHA: ${{ needs.decide.outputs.head_sha }} CHANNEL: ${{ needs.decide.outputs.channel }} CHANNEL_APP_NAME: ${{ needs.decide.outputs.app_name }} with: script: | const { owner, repo } = context.repo; const runUrl = `${context.serverUrl}/${owner}/${repo}/actions/runs/${context.runId}`; const open = await github.rest.issues.listForRepo({ owner, repo, labels: `${process.env.CHANNEL}-failure`, state: 'open', per_page: 10 }); const { covers } = await import(`${process.env.GITHUB_WORKSPACE}/scripts/ci/nightly_failure_issue.mjs`); for (const issue of open.data) { // Push runs build in parallel: an incident a newer commit reported stays open. if (!(await covers({ github, owner, repo, issue, sha: process.env.HEAD_SHA, core }))) continue; await github.rest.issues.createComment({ owner, repo, issue_number: issue.number, body: `${process.env.CHANNEL_APP_NAME} publish succeeded again on ${process.env.HEAD_SHA}: ${runUrl}` }); await github.rest.issues.update({ owner, repo, issue_number: issue.number, state: 'closed' }); }