Skip to content

Merge pull request #1342 from shorepine/amy_version_display #106

Merge pull request #1342 from shorepine/amy_version_display

Merge pull request #1342 from shorepine/amy_version_display #106

Workflow file for this run

name: Tulip firmware release
# Continuous Tulip release on push to main: build TULIP4_R11 firmware (and Tulip
# Desktop for macOS) and publish to the rolling 'tulip' GitHub release.
#
# The rolling 'tulip' release is marked LATEST: it is what the releases page
# badges and what the GitHub API serves at releases/latest. That matters because
# firmware shipped in the monthly releases (<= v-jun-2026, before continuous
# release) OTAs from releases/latest — those Tulips find tulip-firmware-*/
# tulip-sys.bin here, and legacy AMYboards find the amyboard-* bins that
# amyboard-release.yml mirrors onto this release. Current firmware reads
# releases/tags/tulip directly (see get_latest_release() in
# tulip/shared/py/tulip.py). If a monthly release.sh release is ever cut again,
# create it with --latest=false so it doesn't steal releases/latest back.
#
# Only TULIP4_R11 is shipped; TDECK/N16R8/N32R8 are developer-only (build them by
# hand with `idf.py -DMICROPY_BOARD=<board> build`).
#
# Paths are firmware/desktop-only (NOT tulip/web/**) so a web-only change doesn't
# bump the release date; the web app is deployed separately by tulip-web-release.yml.
#
# Publishing is gated on main so a workflow_dispatch from a branch is a safe
# dry-run: everything builds, nothing is uploaded to the release.
on:
push:
branches: [ "main" ]
paths:
- 'amy'
- 'tulip/esp32s3/**'
- 'tulip/shared/**'
- 'tulip/fs/tulip/**'
- 'tulip/fs_create.py'
- 'tulip/macos/**'
- 'tulip/windows/**'
- '.github/workflows/tulip-release.yml'
workflow_dispatch:
concurrency:
group: tulip-firmware-release # serialize releases; never cancel a half-done one
cancel-in-progress: false
jobs:
firmware:
runs-on: ubuntu-latest
permissions:
contents: write # create + clobber the 'tulip' release
steps:
- uses: actions/checkout@v4
- uses: ./.github/actions/init-submodules
# See amyboard-pr-preview.yml for why ccache lives in the workspace
# rather than ~/.ccache. This job runs on main, so unlike the PR previews
# it also SAVES -- PR runs can only read caches from the default branch,
# so main is the only place that can populate one for them.
- name: Prepare ccache dir
run: mkdir -p .ccache
# Key is namespaced by BOARD. Both release workflows fire on the same push,
# so a key of just <prefix>-${{ github.sha }} is identical in both jobs:
# whichever finishes first saves, the other collides and is silently
# dropped. That is deterministic, not a race -- measured on 693d29cd,
# where this job's 1562 objects were the ones discarded.
- name: Restore esp-idf ccache
uses: actions/cache/restore@v4
with:
path: .ccache
key: esp-idf-ccache-v5.4.1-TULIP4_R11-${{ github.sha }}
restore-keys: esp-idf-ccache-v5.4.1-TULIP4_R11-
- name: Build TULIP4_R11 firmware + assemble images
uses: espressif/esp-idf-ci-action@v1
with:
esp_idf_version: v5.4.1
target: esp32s3
path: tulip/esp32s3
command: >-
export IDF_CCACHE_ENABLE=1 &&
export CCACHE_DIR="$(cd ../.. && pwd)/.ccache" &&
export CCACHE_MAXSIZE=800M &&
python -m pip install littlefs-python &&
idf.py -DMICROPY_BOARD=TULIP4_R11 build &&
cd .. &&
python fs_create.py tulip &&
(ccache -s || true)
# The container wrote these as root; actions/cache runs as the runner.
- name: Reclaim ccache ownership after the root container build
if: always()
run: sudo chown -R "$(id -u):$(id -g)" .ccache
- name: Save esp-idf ccache
if: always()
uses: actions/cache/save@v4
with:
path: .ccache
key: esp-idf-ccache-v5.4.1-TULIP4_R11-${{ github.sha }}
- name: Upload firmware artifacts
uses: actions/upload-artifact@v4
with:
name: tulip-firmware
if-no-files-found: error
path: |
tulip/esp32s3/dist/tulip-firmware-TULIP4_R11.bin
tulip/esp32s3/dist/tulip-full-TULIP4_R11.bin
tulip/esp32s3/dist/tulip-sys.bin
- name: Publish to the rolling 'tulip' release
if: github.ref == 'refs/heads/main'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
set -euo pipefail
# Create the rolling release once. It is kept marked LATEST (see the
# header comment: legacy pre-jun-2026 devices OTA from releases/latest).
if ! gh release view tulip >/dev/null 2>&1; then
gh release create tulip \
--target "$GITHUB_SHA" \
--title "Tulip (rolling release)" \
--notes "Rolling Tulip release built from main (TULIP4_R11). OTA'd by tulip.upgrade(). Updated on every push to main; not a tagged version."
fi
# Stable asset names — consumed by tulip.upgrade(), the docs' direct
# download links, and legacy releases/latest OTA. Do not rename.
gh release upload --clobber tulip \
tulip/esp32s3/dist/tulip-firmware-TULIP4_R11.bin \
tulip/esp32s3/dist/tulip-full-TULIP4_R11.bin \
tulip/esp32s3/dist/tulip-sys.bin
# Date-coded copy of the full image for manufacturing ("which build is
# this file?"). One dated copy lives on the release at a time; older
# date codes are pruned. Same-day rebuilds clobber the same name.
# Copy into RUNNER_TEMP: dist/ is root-owned (created inside the
# esp-idf-ci-action container), so this step can't create files there.
DATE_CODE="$(date -u +%Y%m%d)"
cp tulip/esp32s3/dist/tulip-full-TULIP4_R11.bin \
"${RUNNER_TEMP}/tulip-full-TULIP4_R11-${DATE_CODE}.bin"
gh api "repos/${GITHUB_REPOSITORY}/releases/tags/tulip" --jq '.assets[].name' \
| { grep -E '^tulip-full-TULIP4_R11-[0-9]{8}\.bin$' || true; } \
| { grep -v "${DATE_CODE}" || true; } \
| while read -r stale; do gh release delete-asset tulip "$stale" --yes; done
gh release upload --clobber tulip \
"${RUNNER_TEMP}/tulip-full-TULIP4_R11-${DATE_CODE}.bin"
# Refresh the body with the exact released commit for provenance, and
# (re)assert latest so releases/latest keeps serving legacy OTA.
gh release edit tulip \
--latest \
--notes "Rolling Tulip release (TULIP4_R11), built from main @ ${GITHUB_SHA}. OTA'd by tulip.upgrade(). Updated on every push to main; not a tagged version."
# Tulip Desktop for macOS (universal arm64+x86_64), mirroring tulip/macos/
# package.sh. Signing + notarization run only when the Apple credentials are
# present as repo secrets; without them the app is still built (and uploaded
# as a workflow artifact) but NOT published to the release, since an unsigned
# zip would be blocked by Gatekeeper on download.
#
# Required secrets for publishing:
# MACOS_SIGNING_CERT_P12 — base64 of the "Developer ID Application"
# certificate + private key (.p12)
# MACOS_SIGNING_CERT_PASSWORD — password for the .p12
# MACOS_NOTARY_API_KEY — App Store Connect API key file contents (.p8)
# MACOS_NOTARY_API_KEY_ID — key ID for that key
# MACOS_NOTARY_API_ISSUER_ID — issuer ID for that key
desktop:
runs-on: macos-14
permissions:
contents: write # upload Tulip_Desktop.zip to the 'tulip' release
steps:
- uses: actions/checkout@v4
- uses: ./.github/actions/init-submodules
- name: Build Tulip Desktop (universal binary)
run: |
set -euo pipefail
cd tulip/macos
# Mirrors package.sh: build each arch, lipo, assemble the app bundle.
make WHICH_ARCH=arm64
cp build-standard/tulip/obj/tulip.arm64 .
rm -rf build-standard
make WHICH_ARCH=x86_64
cp build-standard/tulip/obj/tulip.x86_64 .
rm -rf build-standard
lipo -create -output tulip tulip.x86_64 tulip.arm64
rm -rf dist
mkdir -p "dist/Tulip Desktop.app/Contents/MacOS" \
"dist/Tulip Desktop.app/Contents/Resources" \
"dist/Tulip Desktop.app/Contents/Frameworks" \
"dist/Tulip Desktop.app/Contents/libs"
cp tulip "dist/Tulip Desktop.app/Contents/MacOS/"
cp Info.plist "dist/Tulip Desktop.app/Contents/"
cp -rf ../fs "dist/Tulip Desktop.app/Contents/Resources/"
cp -a SDL2.framework "dist/Tulip Desktop.app/Contents/Frameworks/"
install_name_tool -add_rpath @executable_path/../Frameworks \
"dist/Tulip Desktop.app/Contents/MacOS/tulip"
cp tulip.icns "dist/Tulip Desktop.app/Contents/Resources/"
- name: Check for signing secrets
id: signing
env:
CERT_P12: ${{ secrets.MACOS_SIGNING_CERT_P12 }}
NOTARY_KEY: ${{ secrets.MACOS_NOTARY_API_KEY }}
run: |
if [ -n "$CERT_P12" ] && [ -n "$NOTARY_KEY" ]; then
echo "enabled=true" >> "$GITHUB_OUTPUT"
else
echo "enabled=false" >> "$GITHUB_OUTPUT"
echo "::warning::macOS signing/notarization secrets not set — building unsigned; Tulip_Desktop.zip will NOT be published to the release."
fi
- name: Sign, notarize, and staple
if: steps.signing.outputs.enabled == 'true'
env:
CERT_P12: ${{ secrets.MACOS_SIGNING_CERT_P12 }}
CERT_PASSWORD: ${{ secrets.MACOS_SIGNING_CERT_PASSWORD }}
NOTARY_KEY: ${{ secrets.MACOS_NOTARY_API_KEY }}
NOTARY_KEY_ID: ${{ secrets.MACOS_NOTARY_API_KEY_ID }}
NOTARY_ISSUER_ID: ${{ secrets.MACOS_NOTARY_API_ISSUER_ID }}
run: |
set -euo pipefail
cd tulip/macos
# Import the Developer ID cert into a throwaway keychain.
KEYCHAIN="$RUNNER_TEMP/tulip-signing.keychain-db"
KEYCHAIN_PASSWORD="$(uuidgen)"
echo "$CERT_P12" | base64 --decode > "$RUNNER_TEMP/cert.p12"
security create-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN"
security set-keychain-settings -lut 21600 "$KEYCHAIN"
security unlock-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN"
security import "$RUNNER_TEMP/cert.p12" -P "$CERT_PASSWORD" \
-A -t cert -f pkcs12 -k "$KEYCHAIN"
security set-key-partition-list -S apple-tool:,apple: \
-k "$KEYCHAIN_PASSWORD" "$KEYCHAIN" >/dev/null
security list-keychains -d user -s "$KEYCHAIN" login.keychain
DEV_ID="$(security find-identity -v -p codesigning "$KEYCHAIN" \
| awk -F'"' '/Developer ID Application/ {print $2; exit}')"
if [ -z "$DEV_ID" ]; then
echo "::error::No 'Developer ID Application' identity found in the imported .p12"; exit 1
fi
# Same signing order as package.sh: framework, binary, then the bundle
# with hardened runtime (required for notarization).
codesign -s "$DEV_ID" -f \
"dist/Tulip Desktop.app/Contents/Frameworks/SDL2.framework/Versions/A/SDL2"
codesign -s "$DEV_ID" -f "dist/Tulip Desktop.app/Contents/MacOS/tulip"
codesign --force --options runtime --timestamp --sign "$DEV_ID" \
"dist/Tulip Desktop.app"
# Notarize with an App Store Connect API key, then staple.
echo "$NOTARY_KEY" > "$RUNNER_TEMP/notary.p8"
cd dist
/usr/bin/ditto -c -k --sequesterRsrc --keepParent "Tulip Desktop.app" Tulip_Desktop.zip
xcrun notarytool submit Tulip_Desktop.zip \
--key "$RUNNER_TEMP/notary.p8" \
--key-id "$NOTARY_KEY_ID" \
--issuer "$NOTARY_ISSUER_ID" \
--wait
rm -rf "Tulip Desktop.app"
unzip -q Tulip_Desktop.zip
xcrun stapler staple "Tulip Desktop.app"
/usr/bin/ditto -c -k --sequesterRsrc --keepParent "Tulip Desktop.app" Tulip_Desktop.zip
- name: Zip unsigned app (no signing secrets)
if: steps.signing.outputs.enabled != 'true'
run: |
set -euo pipefail
cd tulip/macos/dist
/usr/bin/ditto -c -k --sequesterRsrc --keepParent "Tulip Desktop.app" Tulip_Desktop.zip
- name: Upload Tulip Desktop artifact
uses: actions/upload-artifact@v4
with:
name: tulip-desktop-macos
if-no-files-found: error
path: tulip/macos/dist/Tulip_Desktop.zip
- name: Publish Tulip_Desktop.zip to the rolling 'tulip' release
if: github.ref == 'refs/heads/main' && steps.signing.outputs.enabled == 'true'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
set -euo pipefail
if ! gh release view tulip >/dev/null 2>&1; then
echo "::error::rolling 'tulip' release does not exist yet"; exit 1
fi
gh release upload --clobber tulip tulip/macos/dist/Tulip_Desktop.zip
# Tulip Desktop for Windows (x64), MinGW-cross-built inside the
# rack-plugin-toolchain image (ghcr.io/shorepine/rack-plugin-toolchain:19,
# the same prebuilt image the AMYboard VCV jobs use — it carries
# x86_64-w64-mingw32 gcc). Ships Tulip_Desktop_Windows.zip (tulip.exe +
# SDL2.dll). The exe is unsigned — Windows SmartScreen will warn on first
# run (Windows code signing is a separate cert acquisition, unlike the mac
# notarization above). The build keeps the script's validated DEBUG=1
# (-O0) config for now; flipping to -Os is a follow-up once verified on
# real Windows hardware.
desktop-windows:
# arm64 runner (free for public repos): the toolchain image is arm64-only
# (built on an Apple Silicon mac); it cross-compiles to win-x64 regardless
# of host arch. ubuntu-latest (amd64) cannot pull it.
runs-on: ubuntu-24.04-arm
permissions:
contents: write # upload Tulip_Desktop_Windows.zip to the 'tulip' release
steps:
- uses: actions/checkout@v4
- uses: ./.github/actions/init-submodules
- name: Generate AMY drum data
# stdlib-only generator; the build script touches patches.h itself.
run: cd amy && python3 -m amy.headers gamma9001
- name: Build tulip.exe (MinGW cross)
run: |
docker run --rm --user root -v "$PWD":/work -w /work/tulip/windows \
ghcr.io/shorepine/rack-plugin-toolchain:19 \
bash /work/tulip/windows/toolchain-build-windows.sh
- name: Package Tulip_Desktop_Windows.zip
# Write into RUNNER_TEMP: build-standard/ is root-owned (created
# inside the toolchain container), so this step can't create files there.
run: |
set -euo pipefail
cd tulip/windows/build-standard
zip -q "${RUNNER_TEMP}/Tulip_Desktop_Windows.zip" tulip.exe SDL2.dll
- name: Upload Tulip Desktop for Windows artifact
uses: actions/upload-artifact@v4
with:
name: tulip-desktop-windows
if-no-files-found: error
path: ${{ runner.temp }}/Tulip_Desktop_Windows.zip
- name: Publish Tulip_Desktop_Windows.zip to the rolling 'tulip' release
if: github.ref == 'refs/heads/main'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
set -euo pipefail
if ! gh release view tulip >/dev/null 2>&1; then
echo "::error::rolling 'tulip' release does not exist yet"; exit 1
fi
gh release upload --clobber tulip "${RUNNER_TEMP}/Tulip_Desktop_Windows.zip"