Publish separate universal nightly track
This commit is contained in:
parent
939560262f
commit
e23eb285cd
1 changed files with 134 additions and 84 deletions
218
.github/workflows/nightly.yml
vendored
218
.github/workflows/nightly.yml
vendored
|
|
@ -151,9 +151,16 @@ jobs:
|
|||
echo "Derived Sparkle public key: $DERIVED_PUBLIC_KEY"
|
||||
echo "SPARKLE_PUBLIC_KEY=$DERIVED_PUBLIC_KEY" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Build app (Release)
|
||||
- name: Build Apple Silicon app (Release)
|
||||
run: |
|
||||
xcodebuild -scheme cmux -configuration Release -derivedDataPath build \
|
||||
xcodebuild -scheme cmux -configuration Release -derivedDataPath build-arm \
|
||||
-destination 'platform=macOS,arch=arm64' \
|
||||
-clonedSourcePackagesDirPath .spm-cache \
|
||||
CODE_SIGNING_ALLOWED=NO ASSETCATALOG_COMPILER_APPICON_NAME=AppIcon-Nightly build
|
||||
|
||||
- name: Build universal app (Release)
|
||||
run: |
|
||||
xcodebuild -scheme cmux -configuration Release -derivedDataPath build-universal \
|
||||
-destination 'generic/platform=macOS' \
|
||||
-clonedSourcePackagesDirPath .spm-cache \
|
||||
ARCHS="arm64 x86_64" \
|
||||
|
|
@ -163,8 +170,8 @@ jobs:
|
|||
- name: Verify universal binaries
|
||||
run: |
|
||||
set -euo pipefail
|
||||
APP_BINARY="build/Build/Products/Release/cmux.app/Contents/MacOS/cmux"
|
||||
CLI_BINARY="build/Build/Products/Release/cmux.app/Contents/Resources/bin/cmux"
|
||||
APP_BINARY="build-universal/Build/Products/Release/cmux.app/Contents/MacOS/cmux"
|
||||
CLI_BINARY="build-universal/Build/Products/Release/cmux.app/Contents/Resources/bin/cmux"
|
||||
APP_ARCHS="$(lipo -archs "$APP_BINARY")"
|
||||
CLI_ARCHS="$(lipo -archs "$CLI_BINARY")"
|
||||
echo "App binary architectures: $APP_ARCHS"
|
||||
|
|
@ -172,34 +179,15 @@ jobs:
|
|||
[[ "$APP_ARCHS" == *arm64* && "$APP_ARCHS" == *x86_64* ]]
|
||||
[[ "$CLI_ARCHS" == *arm64* && "$CLI_ARCHS" == *x86_64* ]]
|
||||
|
||||
- name: Inject nightly identity and metadata
|
||||
- name: Inject nightly identities and metadata
|
||||
run: |
|
||||
set -euo pipefail
|
||||
APP_DIR="build/Build/Products/Release"
|
||||
APP_PLIST="${APP_DIR}/cmux.app/Contents/Info.plist"
|
||||
SHORT_SHA="${{ needs.decide.outputs.short_sha }}"
|
||||
ARM_APP_DIR="build-arm/Build/Products/Release"
|
||||
UNIVERSAL_APP_DIR="build-universal/Build/Products/Release"
|
||||
|
||||
# --- Separate app identity: "cmux NIGHTLY" with its own bundle ID ---
|
||||
/usr/libexec/PlistBuddy -c "Set :CFBundleName cmux NIGHTLY" "$APP_PLIST"
|
||||
/usr/libexec/PlistBuddy -c "Set :CFBundleDisplayName cmux NIGHTLY" "$APP_PLIST"
|
||||
/usr/libexec/PlistBuddy -c "Set :CFBundleIdentifier com.cmuxterm.app.nightly" "$APP_PLIST"
|
||||
|
||||
# Rename the .app bundle to match the display name
|
||||
mv "${APP_DIR}/cmux.app" "${APP_DIR}/cmux NIGHTLY.app"
|
||||
|
||||
# Update plist path after rename
|
||||
APP_PLIST="${APP_DIR}/cmux NIGHTLY.app/Contents/Info.plist"
|
||||
|
||||
# --- Sparkle: point at the nightly appcast ---
|
||||
/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 https://github.com/manaflow-ai/cmux/releases/download/nightly/appcast.xml" "$APP_PLIST"
|
||||
|
||||
# Marketing version: append -nightly.YYYYMMDD so users can identify the channel and date
|
||||
BASE_MARKETING=$(/usr/libexec/PlistBuddy -c "Print :CFBundleShortVersionString" "$APP_PLIST")
|
||||
BASE_MARKETING=$(/usr/libexec/PlistBuddy -c "Print :CFBundleShortVersionString" "${ARM_APP_DIR}/cmux.app/Contents/Info.plist")
|
||||
NIGHTLY_DATE=$(date -u +%Y%m%d)
|
||||
/usr/libexec/PlistBuddy -c "Set :CFBundleShortVersionString ${BASE_MARKETING}-nightly.${NIGHTLY_DATE}" "$APP_PLIST"
|
||||
|
||||
# Build number: unique/monotonic per workflow run attempt so same-day
|
||||
# nightlies and reruns still compare as newer in Sparkle.
|
||||
|
|
@ -209,23 +197,49 @@ jobs:
|
|||
else
|
||||
NIGHTLY_BUILD="${NIGHTLY_DATE}000000"
|
||||
fi
|
||||
/usr/libexec/PlistBuddy -c "Set :CFBundleVersion ${NIGHTLY_BUILD}" "$APP_PLIST"
|
||||
|
||||
# Use an immutable DMG filename in appcast URLs so old appcasts keep
|
||||
# pointing at matching archives while nightly assets roll forward.
|
||||
NIGHTLY_DMG_IMMUTABLE="cmux-nightly-macos-${NIGHTLY_BUILD}.dmg"
|
||||
echo "NIGHTLY_BUILD=${NIGHTLY_BUILD}" >> "$GITHUB_ENV"
|
||||
echo "NIGHTLY_DMG_IMMUTABLE=${NIGHTLY_DMG_IMMUTABLE}" >> "$GITHUB_ENV"
|
||||
|
||||
# Embed commit SHA for bug reports
|
||||
/usr/libexec/PlistBuddy -c "Delete :CMUXCommit" "$APP_PLIST" >/dev/null 2>&1 || true
|
||||
/usr/libexec/PlistBuddy -c "Add :CMUXCommit string ${SHORT_SHA}" "$APP_PLIST"
|
||||
ARM_DMG_IMMUTABLE="cmux-nightly-macos-${NIGHTLY_BUILD}.dmg"
|
||||
UNIVERSAL_DMG_IMMUTABLE="cmux-nightly-universal-macos-${NIGHTLY_BUILD}.dmg"
|
||||
echo "NIGHTLY_DMG_IMMUTABLE=${ARM_DMG_IMMUTABLE}" >> "$GITHUB_ENV"
|
||||
echo "NIGHTLY_UNIVERSAL_DMG_IMMUTABLE=${UNIVERSAL_DMG_IMMUTABLE}" >> "$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 cmux NIGHTLY" "$app_plist"
|
||||
/usr/libexec/PlistBuddy -c "Set :CFBundleDisplayName cmux NIGHTLY" "$app_plist"
|
||||
/usr/libexec/PlistBuddy -c "Set :CFBundleIdentifier ${bundle_id}" "$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 ${BASE_MARKETING}-nightly.${NIGHTLY_DATE}" "$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"
|
||||
mv "$app_dir/cmux.app" "$app_dir/cmux NIGHTLY.app"
|
||||
}
|
||||
|
||||
prepare_variant \
|
||||
"$ARM_APP_DIR" \
|
||||
"com.cmuxterm.app.nightly" \
|
||||
"https://github.com/manaflow-ai/cmux/releases/download/nightly/appcast.xml"
|
||||
prepare_variant \
|
||||
"$UNIVERSAL_APP_DIR" \
|
||||
"com.cmuxterm.app.nightly.universal" \
|
||||
"https://github.com/manaflow-ai/cmux/releases/download/nightly/appcast-universal.xml"
|
||||
|
||||
echo "Nightly app name: cmux NIGHTLY"
|
||||
echo "Nightly bundle ID: com.cmuxterm.app.nightly"
|
||||
echo "Nightly arm64 bundle ID: com.cmuxterm.app.nightly"
|
||||
echo "Nightly universal bundle ID: com.cmuxterm.app.nightly.universal"
|
||||
echo "Nightly marketing version: ${BASE_MARKETING}-nightly.${NIGHTLY_DATE}"
|
||||
echo "Nightly build number: ${NIGHTLY_BUILD}"
|
||||
echo "Nightly immutable DMG: ${NIGHTLY_DMG_IMMUTABLE}"
|
||||
echo "Nightly arm64 immutable DMG: ${ARM_DMG_IMMUTABLE}"
|
||||
echo "Nightly universal immutable DMG: ${UNIVERSAL_DMG_IMMUTABLE}"
|
||||
echo "Commit SHA: ${SHORT_SHA}"
|
||||
|
||||
- name: Import signing cert
|
||||
|
|
@ -251,7 +265,7 @@ jobs:
|
|||
security set-key-partition-list -S apple-tool:,apple: -s -k "$KEYCHAIN_PASSWORD" build.keychain
|
||||
security list-keychains -d user -s build.keychain
|
||||
|
||||
- name: Codesign app
|
||||
- name: Codesign apps
|
||||
env:
|
||||
APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }}
|
||||
run: |
|
||||
|
|
@ -259,16 +273,20 @@ jobs:
|
|||
echo "Missing APPLE_SIGNING_IDENTITY secret" >&2
|
||||
exit 1
|
||||
fi
|
||||
APP_PATH="build/Build/Products/Release/cmux NIGHTLY.app"
|
||||
ENTITLEMENTS="cmux.entitlements"
|
||||
CLI_PATH="$APP_PATH/Contents/Resources/bin/cmux"
|
||||
if [ -f "$CLI_PATH" ]; then
|
||||
/usr/bin/codesign --force --options runtime --timestamp --sign "$APPLE_SIGNING_IDENTITY" --entitlements "$ENTITLEMENTS" "$CLI_PATH"
|
||||
fi
|
||||
/usr/bin/codesign --force --options runtime --timestamp --sign "$APPLE_SIGNING_IDENTITY" --entitlements "$ENTITLEMENTS" --deep "$APP_PATH"
|
||||
/usr/bin/codesign --verify --deep --strict --verbose=2 "$APP_PATH"
|
||||
for APP_PATH in \
|
||||
"build-arm/Build/Products/Release/cmux NIGHTLY.app" \
|
||||
"build-universal/Build/Products/Release/cmux NIGHTLY.app"
|
||||
do
|
||||
CLI_PATH="$APP_PATH/Contents/Resources/bin/cmux"
|
||||
if [ -f "$CLI_PATH" ]; then
|
||||
/usr/bin/codesign --force --options runtime --timestamp --sign "$APPLE_SIGNING_IDENTITY" --entitlements "$ENTITLEMENTS" "$CLI_PATH"
|
||||
fi
|
||||
/usr/bin/codesign --force --options runtime --timestamp --sign "$APPLE_SIGNING_IDENTITY" --entitlements "$ENTITLEMENTS" --deep "$APP_PATH"
|
||||
/usr/bin/codesign --verify --deep --strict --verbose=2 "$APP_PATH"
|
||||
done
|
||||
|
||||
- name: Notarize app and dmg
|
||||
- name: Notarize apps and dmgs
|
||||
env:
|
||||
APPLE_ID: ${{ secrets.APPLE_ID }}
|
||||
APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }}
|
||||
|
|
@ -279,41 +297,62 @@ jobs:
|
|||
echo "Missing notarization secrets (APPLE_ID, APPLE_APP_SPECIFIC_PASSWORD, APPLE_TEAM_ID)" >&2
|
||||
exit 1
|
||||
fi
|
||||
APP_PATH="build/Build/Products/Release/cmux NIGHTLY.app"
|
||||
ZIP_SUBMIT="cmux-nightly-notary.zip"
|
||||
DMG_RELEASE="cmux-nightly-macos.dmg"
|
||||
ditto -c -k --sequesterRsrc --keepParent "$APP_PATH" "$ZIP_SUBMIT"
|
||||
APP_SUBMIT_JSON="$(xcrun notarytool submit "$ZIP_SUBMIT" --apple-id "$APPLE_ID" --team-id "$APPLE_TEAM_ID" --password "$APPLE_APP_SPECIFIC_PASSWORD" --wait --output-format json)"
|
||||
APP_SUBMIT_ID="$(python3 -c 'import json,sys; print(json.load(sys.stdin)["id"])' <<<"$APP_SUBMIT_JSON")"
|
||||
APP_STATUS="$(python3 -c 'import json,sys; print(json.load(sys.stdin)["status"])' <<<"$APP_SUBMIT_JSON")"
|
||||
if [ "$APP_STATUS" != "Accepted" ]; then
|
||||
echo "App notarization failed with status: $APP_STATUS" >&2
|
||||
xcrun notarytool log "$APP_SUBMIT_ID" --apple-id "$APPLE_ID" --team-id "$APPLE_TEAM_ID" --password "$APPLE_APP_SPECIFIC_PASSWORD" || true
|
||||
exit 1
|
||||
fi
|
||||
xcrun stapler staple "$APP_PATH"
|
||||
xcrun stapler validate "$APP_PATH"
|
||||
spctl -a -vv --type execute "$APP_PATH"
|
||||
rm -f "$ZIP_SUBMIT"
|
||||
create-dmg \
|
||||
--identity="$APPLE_SIGNING_IDENTITY" \
|
||||
"$APP_PATH" \
|
||||
./
|
||||
mv ./"cmux NIGHTLY"*.dmg "$DMG_RELEASE" 2>/dev/null || mv ./cmux*.dmg "$DMG_RELEASE"
|
||||
DMG_SUBMIT_JSON="$(xcrun notarytool submit "$DMG_RELEASE" --apple-id "$APPLE_ID" --team-id "$APPLE_TEAM_ID" --password "$APPLE_APP_SPECIFIC_PASSWORD" --wait --output-format json)"
|
||||
DMG_SUBMIT_ID="$(python3 -c 'import json,sys; print(json.load(sys.stdin)["id"])' <<<"$DMG_SUBMIT_JSON")"
|
||||
DMG_STATUS="$(python3 -c 'import json,sys; print(json.load(sys.stdin)["status"])' <<<"$DMG_SUBMIT_JSON")"
|
||||
if [ "$DMG_STATUS" != "Accepted" ]; then
|
||||
echo "DMG notarization failed with status: $DMG_STATUS" >&2
|
||||
xcrun notarytool log "$DMG_SUBMIT_ID" --apple-id "$APPLE_ID" --team-id "$APPLE_TEAM_ID" --password "$APPLE_APP_SPECIFIC_PASSWORD" || true
|
||||
exit 1
|
||||
fi
|
||||
xcrun stapler staple "$DMG_RELEASE"
|
||||
xcrun stapler validate "$DMG_RELEASE"
|
||||
notarize_and_package() {
|
||||
local app_path="$1"
|
||||
local dmg_release="$2"
|
||||
local dmg_immutable="$3"
|
||||
local zip_submit="${dmg_release%.dmg}-notary.zip"
|
||||
local dmg_tmp_dir
|
||||
local created_dmg
|
||||
|
||||
# Keep a stable filename for humans and an immutable filename used
|
||||
# by appcast URLs to prevent signature/asset mismatch races.
|
||||
cp "$DMG_RELEASE" "$NIGHTLY_DMG_IMMUTABLE"
|
||||
ditto -c -k --sequesterRsrc --keepParent "$app_path" "$zip_submit"
|
||||
APP_SUBMIT_JSON="$(xcrun notarytool submit "$zip_submit" --apple-id "$APPLE_ID" --team-id "$APPLE_TEAM_ID" --password "$APPLE_APP_SPECIFIC_PASSWORD" --wait --output-format json)"
|
||||
APP_SUBMIT_ID="$(python3 -c 'import json,sys; print(json.load(sys.stdin)["id"])' <<<"$APP_SUBMIT_JSON")"
|
||||
APP_STATUS="$(python3 -c 'import json,sys; print(json.load(sys.stdin)["status"])' <<<"$APP_SUBMIT_JSON")"
|
||||
if [ "$APP_STATUS" != "Accepted" ]; then
|
||||
echo "App notarization failed for $app_path with status: $APP_STATUS" >&2
|
||||
xcrun notarytool log "$APP_SUBMIT_ID" --apple-id "$APPLE_ID" --team-id "$APPLE_TEAM_ID" --password "$APPLE_APP_SPECIFIC_PASSWORD" || true
|
||||
exit 1
|
||||
fi
|
||||
xcrun stapler staple "$app_path"
|
||||
xcrun stapler validate "$app_path"
|
||||
spctl -a -vv --type execute "$app_path"
|
||||
rm -f "$zip_submit"
|
||||
|
||||
dmg_tmp_dir="$(mktemp -d)"
|
||||
create-dmg \
|
||||
--identity="$APPLE_SIGNING_IDENTITY" \
|
||||
"$app_path" \
|
||||
"$dmg_tmp_dir"
|
||||
created_dmg="$(find "$dmg_tmp_dir" -maxdepth 1 -name '*.dmg' | head -n 1)"
|
||||
if [ -z "$created_dmg" ]; then
|
||||
echo "Failed to locate created DMG for $app_path" >&2
|
||||
exit 1
|
||||
fi
|
||||
mv "$created_dmg" "$dmg_release"
|
||||
rm -rf "$dmg_tmp_dir"
|
||||
|
||||
DMG_SUBMIT_JSON="$(xcrun notarytool submit "$dmg_release" --apple-id "$APPLE_ID" --team-id "$APPLE_TEAM_ID" --password "$APPLE_APP_SPECIFIC_PASSWORD" --wait --output-format json)"
|
||||
DMG_SUBMIT_ID="$(python3 -c 'import json,sys; print(json.load(sys.stdin)["id"])' <<<"$DMG_SUBMIT_JSON")"
|
||||
DMG_STATUS="$(python3 -c 'import json,sys; print(json.load(sys.stdin)["status"])' <<<"$DMG_SUBMIT_JSON")"
|
||||
if [ "$DMG_STATUS" != "Accepted" ]; then
|
||||
echo "DMG notarization failed for $dmg_release with status: $DMG_STATUS" >&2
|
||||
xcrun notarytool log "$DMG_SUBMIT_ID" --apple-id "$APPLE_ID" --team-id "$APPLE_TEAM_ID" --password "$APPLE_APP_SPECIFIC_PASSWORD" || true
|
||||
exit 1
|
||||
fi
|
||||
xcrun stapler staple "$dmg_release"
|
||||
xcrun stapler validate "$dmg_release"
|
||||
cp "$dmg_release" "$dmg_immutable"
|
||||
}
|
||||
|
||||
notarize_and_package \
|
||||
"build-arm/Build/Products/Release/cmux NIGHTLY.app" \
|
||||
"cmux-nightly-macos.dmg" \
|
||||
"$NIGHTLY_DMG_IMMUTABLE"
|
||||
notarize_and_package \
|
||||
"build-universal/Build/Products/Release/cmux NIGHTLY.app" \
|
||||
"cmux-nightly-universal-macos.dmg" \
|
||||
"$NIGHTLY_UNIVERSAL_DMG_IMMUTABLE"
|
||||
|
||||
- name: Upload dSYMs to Sentry
|
||||
env:
|
||||
|
|
@ -326,9 +365,11 @@ jobs:
|
|||
exit 0
|
||||
fi
|
||||
brew install getsentry/tools/sentry-cli || true
|
||||
sentry-cli debug-files upload --include-sources build/Build/Products/Release/
|
||||
sentry-cli debug-files upload --include-sources \
|
||||
build-arm/Build/Products/Release/ \
|
||||
build-universal/Build/Products/Release/
|
||||
|
||||
- name: Generate Sparkle appcast (nightly)
|
||||
- name: Generate Sparkle appcasts (nightly)
|
||||
env:
|
||||
SPARKLE_PRIVATE_KEY: ${{ secrets.SPARKLE_PRIVATE_KEY }}
|
||||
run: |
|
||||
|
|
@ -337,6 +378,7 @@ jobs:
|
|||
exit 1
|
||||
fi
|
||||
./scripts/sparkle_generate_appcast.sh "$NIGHTLY_DMG_IMMUTABLE" nightly appcast.xml
|
||||
./scripts/sparkle_generate_appcast.sh "$NIGHTLY_UNIVERSAL_DMG_IMMUTABLE" nightly appcast-universal.xml
|
||||
|
||||
- name: Upload branch nightly artifacts
|
||||
if: needs.decide.outputs.should_publish != 'true'
|
||||
|
|
@ -345,7 +387,9 @@ jobs:
|
|||
name: cmux-nightly-${{ needs.decide.outputs.short_sha }}
|
||||
path: |
|
||||
cmux-nightly-macos*.dmg
|
||||
cmux-nightly-universal-macos*.dmg
|
||||
appcast.xml
|
||||
appcast-universal.xml
|
||||
if-no-files-found: error
|
||||
|
||||
- name: Move nightly tag to built commit
|
||||
|
|
@ -368,13 +412,19 @@ jobs:
|
|||
body: |
|
||||
Automated nightly build for `${{ needs.decide.outputs.short_sha }}`.
|
||||
|
||||
**cmux NIGHTLY** is a separate app (bundle ID `com.cmuxterm.app.nightly`) that can be installed alongside the stable release. It receives nightly updates automatically via its own Sparkle feed.
|
||||
**cmux NIGHTLY** has two update tracks:
|
||||
- Apple Silicon: bundle ID `com.cmuxterm.app.nightly`, feed `appcast.xml`
|
||||
- Universal: bundle ID `com.cmuxterm.app.nightly.universal`, feed `appcast-universal.xml`
|
||||
|
||||
[Download cmux-nightly-macos.dmg](https://github.com/manaflow-ai/cmux/releases/download/nightly/cmux-nightly-macos.dmg)
|
||||
[Download cmux-nightly-universal-macos.dmg](https://github.com/manaflow-ai/cmux/releases/download/nightly/cmux-nightly-universal-macos.dmg)
|
||||
files: |
|
||||
cmux-nightly-macos-${{ github.run_id }}*.dmg
|
||||
cmux-nightly-macos.dmg
|
||||
cmux-nightly-universal-macos-${{ github.run_id }}*.dmg
|
||||
cmux-nightly-universal-macos.dmg
|
||||
appcast.xml
|
||||
appcast-universal.xml
|
||||
overwrite_files: true
|
||||
|
||||
- name: Cleanup keychain
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue