Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -635,6 +635,9 @@ jobs:
ONLY_ACTIVE_ARCH=NO \
CODE_SIGNING_ALLOWED=NO ASSETCATALOG_COMPILER_APPICON_NAME=AppIcon-Nightly build

- name: Validate app bundle channel metadata verifier
run: ./tests/test_verify_app_bundle_channel_metadata.sh

ui-regressions:
runs-on: warp-macos-15-arm64-6x
timeout-minutes: 25
Expand Down
1 change: 1 addition & 0 deletions .github/workflows/nightly.yml
Original file line number Diff line number Diff line change
Expand Up @@ -329,6 +329,7 @@ jobs:
--version "$NIGHTLY_REMOTE_DAEMON_VERSION" \
--release-tag "nightly" \
--repo "manaflow-ai/cmux" \
--signer-workflow "manaflow-ai/cmux/.github/workflows/nightly.yml" \
--output-dir "remote-daemon-assets" \
--asset-suffix "$NIGHTLY_BUILD"
MANIFEST_JSON="$(python3 -c 'import json,sys; print(json.dumps(json.load(open(sys.argv[1], encoding="utf-8")), separators=(",",":")))' "remote-daemon-assets/cmuxd-remote-manifest-${NIGHTLY_BUILD}.json")"
Expand Down
285 changes: 285 additions & 0 deletions .github/workflows/promote-release-candidate.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,285 @@
name: Promote release candidate

on:
workflow_dispatch:
inputs:
rc_tag:
description: RC tag to promote, for example v0.64.0-rc.1
required: true
type: string

concurrency:
group: promote-release-candidate
cancel-in-progress: false

permissions:
contents: write
actions: write

jobs:
promote:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2

- name: Resolve tags
id: vars
env:
INPUT_RC_TAG: ${{ inputs.rc_tag }}
run: |
set -euo pipefail
RC_TAG="$INPUT_RC_TAG"
if ! [[ "$RC_TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+-rc\.[0-9]+$ ]]; then
echo "rc_tag must look like v0.64.0-rc.1, got: $RC_TAG" >&2
exit 1
Comment thread
coderabbitai[bot] marked this conversation as resolved.
fi
STABLE_TAG="${RC_TAG%%-rc.*}"
VERSION="${STABLE_TAG#v}"
git fetch origin "refs/tags/${RC_TAG}:refs/tags/${RC_TAG}"
CANDIDATE_SHA="$(git rev-list -n 1 "$RC_TAG")"
{
echo "RC_TAG=$RC_TAG"
echo "STABLE_TAG=$STABLE_TAG"
echo "VERSION=$VERSION"
echo "CANDIDATE_SHA=$CANDIDATE_SHA"
} >> "$GITHUB_ENV"
{
echo "rc_tag=$RC_TAG"
echo "stable_tag=$STABLE_TAG"
echo "version=$VERSION"
echo "candidate_sha=$CANDIDATE_SHA"
} >> "$GITHUB_OUTPUT"
{
echo "### Promote release candidate"
echo
echo "- rc tag: \`$RC_TAG\`"
echo "- stable tag: \`$STABLE_TAG\`"
echo "- candidate sha: \`$CANDIDATE_SHA\`"
} >> "$GITHUB_STEP_SUMMARY"

- name: Guard immutable stable release assets
id: guard_stable_assets
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
env:
STABLE_TAG: ${{ steps.vars.outputs.stable_tag }}
CANDIDATE_SHA: ${{ steps.vars.outputs.candidate_sha }}
with:
script: |
const {
IMMUTABLE_RELEASE_ASSETS,
evaluateReleaseAssetGuard,
} = require('./scripts/release_asset_guard');
const { owner, repo } = context.repo;
const stableTag = process.env.STABLE_TAG;
const candidateSha = process.env.CANDIDATE_SHA;

async function resolveTagSha(tagName) {
const ref = await github.rest.git.getRef({
owner,
repo,
ref: `tags/${tagName}`,
});
if (ref.data.object.type !== 'tag') {
return ref.data.object.sha;
}
const tag = await github.rest.git.getTag({
owner,
repo,
tag_sha: ref.data.object.sha,
});
return tag.data.object.sha;
}

core.setOutput('skip_release_upload', 'false');
core.setOutput('appcast_source', 'candidate');
try {
const release = await github.rest.repos.getReleaseByTag({
owner,
repo,
tag: stableTag,
});
let existingTagSha;
try {
existingTagSha = await resolveTagSha(stableTag);
} catch (tagError) {
core.setFailed(
`Release ${stableTag} exists but refs/tags/${stableTag} could not be resolved: ${tagError.message}`
);
return;
}
if (existingTagSha !== candidateSha) {
core.setFailed(
`Release ${stableTag} already points to ${existingTagSha}, ` +
`not candidate ${candidateSha}. Publish a new stable version instead of replacing it.`
);
return;
}
const existingAssetNames = (release.data.assets || []).map((asset) => asset.name);
const {
conflicts,
missingImmutableAssets,
hasPartialConflict,
shouldSkipBuildAndUpload,
} = evaluateReleaseAssetGuard({
existingAssetNames,
immutableAssetNames: IMMUTABLE_RELEASE_ASSETS,
});

if (hasPartialConflict) {
core.setFailed(
`Release ${stableTag} has partial immutable assets. Existing: ${conflicts.join(', ')}. ` +
`Missing: ${missingImmutableAssets.join(', ')}. Resolve release assets manually.`
);
return;
}

if (shouldSkipBuildAndUpload) {
core.notice(`Release ${stableTag} already has complete immutable assets. Reusing release appcast for post-publish steps.`);
core.setOutput('skip_release_upload', 'true');
core.setOutput('appcast_source', 'release');
return;
Comment thread
cursor[bot] marked this conversation as resolved.
}
} catch (error) {
if (error.status === 404) {
try {
const existingTagSha = await resolveTagSha(stableTag);
if (existingTagSha !== candidateSha) {
core.setFailed(
`Tag ${stableTag} already points to ${existingTagSha}, ` +
`not candidate ${candidateSha}. Publish a new stable version instead of replacing it.`
);
return;
}
core.notice(`Tag ${stableTag} already points to candidate ${candidateSha}; safe to create the release.`);
} catch (tagError) {
if (tagError.status !== 404) {
throw tagError;
}
core.notice(`Release ${stableTag} and refs/tags/${stableTag} do not exist yet; safe to publish.`);
}
return;
}
throw error;
}

- name: Download stable candidate assets from RC release
if: steps.guard_stable_assets.outputs.skip_release_upload != 'true'
env:
GH_TOKEN: ${{ github.token }}
run: |
set -euo pipefail
mkdir -p rc-assets remote-daemon-assets
gh release download "$RC_TAG" \
--repo "$GITHUB_REPOSITORY" \
--pattern 'stable-candidate-*' \
--dir rc-assets

mv rc-assets/stable-candidate-cmux-macos.dmg cmux-macos.dmg
mv rc-assets/stable-candidate-appcast.xml appcast.xml
for asset in \
cmuxd-remote-darwin-arm64 \
cmuxd-remote-darwin-amd64 \
cmuxd-remote-linux-arm64 \
cmuxd-remote-linux-amd64 \
cmuxd-remote-checksums.txt \
cmuxd-remote-manifest.json
do
mv "rc-assets/stable-candidate-${asset}" "remote-daemon-assets/${asset}"
done

grep -F "releases/download/${STABLE_TAG}/cmux-macos.dmg" appcast.xml >/dev/null
if grep -F "$RC_TAG" appcast.xml >/dev/null; then
echo "Stable appcast unexpectedly references $RC_TAG" >&2
exit 1
fi

- name: Publish stable release from candidate assets
if: steps.guard_stable_assets.outputs.skip_release_upload != 'true'
env:
GH_TOKEN: ${{ github.token }}
run: |
set -euo pipefail
FILES=(
cmux-macos.dmg
appcast.xml
remote-daemon-assets/cmuxd-remote-darwin-arm64
remote-daemon-assets/cmuxd-remote-darwin-amd64
remote-daemon-assets/cmuxd-remote-linux-arm64
remote-daemon-assets/cmuxd-remote-linux-amd64
remote-daemon-assets/cmuxd-remote-checksums.txt
remote-daemon-assets/cmuxd-remote-manifest.json
)

EXISTING_STABLE_TAGS="$(gh release list --repo "$GITHUB_REPOSITORY" --exclude-drafts --exclude-pre-releases \
--json tagName -q '.[].tagName')"
LATEST_STABLE_TAG="$(printf '%s\n%s\n' "$EXISTING_STABLE_TAGS" "$STABLE_TAG" | sed '/^$/d' | sort -V | tail -1)"
LATEST_FLAG=(--latest=false)
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
if [ "$LATEST_STABLE_TAG" = "$STABLE_TAG" ]; then
LATEST_FLAG=(--latest)
fi
Comment thread
lawrencecchen marked this conversation as resolved.

if gh release view "$STABLE_TAG" --repo "$GITHUB_REPOSITORY" >/dev/null 2>&1; then
gh release upload "$STABLE_TAG" "${FILES[@]}" \
--repo "$GITHUB_REPOSITORY"
else
gh release create "$STABLE_TAG" "${FILES[@]}" \
--repo "$GITHUB_REPOSITORY" \
--target "$CANDIDATE_SHA" \
--title "$STABLE_TAG" \
--generate-notes \
"${LATEST_FLAG[@]}"
fi
Comment thread
lawrencecchen marked this conversation as resolved.

- name: Upload stable appcast to R2
env:
Comment thread
coderabbitai[bot] marked this conversation as resolved.
GH_TOKEN: ${{ github.token }}
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"
APPCAST_SOURCE: ${{ steps.guard_stable_assets.outputs.appcast_source }}
run: |
set -euo pipefail
command -v aws >/dev/null 2>&1 || { echo "Installing AWS CLI..."; python3 -m pip install --user awscli; }
export PATH="$HOME/.local/bin:$PATH"
echo "$HOME/.local/bin" >> "$GITHUB_PATH"
if [ "${APPCAST_SOURCE:-candidate}" = "release" ]; then
rm -f appcast.xml
gh release download "$STABLE_TAG" \
--repo "$GITHUB_REPOSITORY" \
--pattern appcast.xml \
--dir .
fi
test -s appcast.xml

STABLE_RELEASE_TAGS="$(gh release list --repo "$GITHUB_REPOSITORY" --exclude-drafts --exclude-pre-releases \
--json tagName -q '.[].tagName')"
LATEST="$(printf '%s\n%s\n' "$STABLE_RELEASE_TAGS" "$STABLE_TAG" | sed '/^$/d' | sort -V | tail -1)"
if [ -n "$LATEST" ] && [ "$LATEST" != "$STABLE_TAG" ]; then
echo "Skipping R2 stable upload: $STABLE_TAG is not the latest release ($LATEST)"
exit 0
fi

aws s3 cp appcast.xml \
"s3://cmux-binaries/stable/appcast.xml" \
--endpoint-url "$R2_ENDPOINT" \
--cache-control "no-cache, no-store, must-revalidate"

- name: Trigger Homebrew cask update
continue-on-error: true
env:
GH_TOKEN: ${{ github.token }}
run: |
set -euo pipefail
STABLE_RELEASE_TAGS="$(gh release list --repo "$GITHUB_REPOSITORY" --exclude-drafts --exclude-pre-releases \
--json tagName -q '.[].tagName')"
LATEST="$(printf '%s\n%s\n' "$STABLE_RELEASE_TAGS" "$STABLE_TAG" | sed '/^$/d' | sort -V | tail -1)"
if [ -n "$LATEST" ] && [ "$LATEST" != "$STABLE_TAG" ]; then
echo "Skipping Homebrew update: $STABLE_TAG is not the latest release ($LATEST)"
exit 0
fi

gh workflow run update-homebrew.yml \
--repo "$GITHUB_REPOSITORY" \
-f version="$VERSION"
Comment thread
cursor[bot] marked this conversation as resolved.
Loading
Loading