Cutting a PLASMA release¶
Internal maintainer doc. Not part of the operator playbook.
What a version tag triggers¶
Pushing a vX.Y.Z tag (matching plasma.__version__, enforced by
publish.yml's check-version job) fires two workflows:
build.yml— builds all four platforms in PyInstaller onedir mode (a folder — the exe plus a support-files directory — not a single self-extracting file; switched from onefile to cut launch time, since onefile re-extracts the whole bundle to a temp dir on every run), smoke-tests each, and attaches release assets:- macOS:
PLASMA_MacOS_arm64.zip(raw onedir folder),.app.zip(.github/build_macos_app.sh— the Homebrew cask's install target; see the "why not PyInstaller's ownBUNDLE()" comment at the top of that script), and.dmg(.github/build_macos_dmg.sh, drag-to- Applications). All three get a companion.sha256file. The.appgets codesigned + notarized + stapled by.github/codesign_notarize_macos.shbetween those two steps — unless the signing secrets aren't configured, in which case it's a no-op and the build stays unsigned (see "Codesigning & notarization" below). The raw.zipis never signed either way (only the.appinside the.app.zip/.dmgis). - Windows:
PLASMA_Windows_x64.zip(raw onedir folder) andPLASMA_Windows_x64_Setup.exe(Inno Setup,packaging/windows/plasma_installer.iss— unsigned, so SmartScreen still warns on first run). - Linux (x64 + arm64):
PLASMA_Linux_<arch>.tar.gz(raw onedir folder only — no installer for Linux by design).
- macOS:
publish.yml— builds the sdist/wheel and publishesplasma-appto PyPI.
Both are source-of-truth downstream of the tag; nothing else needs to run to make a release "live" on PyPI or GitHub Releases.
build.yml pins all platform checkouts to the exact commit tested by its
test job. Builds upload artifacts to the current workflow run; a single
release job downloads and checks the complete asset set and publishes only
after all four platform builds succeed. A failed macOS build therefore leaves
the previous nightly downloads intact for every platform. Nightly runs move
the rolling nightly tag to the tested commit after the artifact checks.
Release uploads are serialized by workflow concurrency. Ordinary manual dispatch
builds still produce workflow artifacts without publishing a release.
.github/workflows/nightly.yml schedules a small dispatcher at 07:00 UTC daily.
GitHub runs cron workflows on the default branch, so this dispatcher explicitly
launches build.yml with ref dev and the boolean nightly input enabled.
The resulting build run is associated with dev, uses the workflow definition
on dev, and pins every platform to that dispatch's source commit. Nightly
dispatches from other branches fail before building. To run a nightly manually,
dispatch nightly.yml, or select branch dev in build.yml and enable its
nightly input. The dispatcher succeeding means only that the build was requested;
its actual result is reported by the separate Build PLASMA run.
Deploy the updated build.yml and supporting signing script to dev, and merge
the workflow changes into the default branch so the scheduled dispatcher and
manual dispatch trigger are registered. Remove the old build.yml cron trigger
there as included in this change, so one nightly is requested each day. Prior
scheduled build records retain their original default-branch label.
Codesigning & notarization (macOS)¶
build-macos has separate Codesign and verify .app, Notarize .app with
Apple, and Staple and validate notarization ticket steps. They invoke
.github/codesign_notarize_macos.sh with sign, notarize, and staple
respectively; invoking the script without arguments still runs all three stages.
It signs dist/PLASMA.app with a "Developer ID Application" certificate,
submits it to Apple's notary service, and staples the resulting ticket.
Without a signing certificate configured, all three steps no-op and every macOS
asset ships unsigned/unnotarized exactly as before (first launch blocked by
Gatekeeper, right-click → Open). Once the certificate is set, missing required
secrets fail explicitly. Notarization must return Accepted before stapling,
and the stapled ticket is validated. The notarization step has a 30-minute
timeout rather than waiting indefinitely.
If notarization returns HTTP 403: a required agreement is missing or expired, the Apple Developer Account Holder must sign into the account portal, accept pending developer agreements, and verify that membership is active. This is an Apple account restriction; reducing build frequency or replacing signing certificates does not resolve it. Once corrected, rerun the failed macOS job; publishing remains gated until all builds succeed.
One-time setup, on the Apple side (requires an Apple Developer Program membership, $99/yr):
- Create a Developer ID Application certificate: Xcode → Settings → Accounts → Manage Certificates → + → Developer ID Application (or via the Certificates page using a CSR generated in Keychain Access). This is the certificate for distributing outside the App Store — not "Apple Development" or "Apple Distribution".
- In Keychain Access, find that certificate, expand it, select both the
certificate and its private key, right-click → Export 2 items… → save
as a
.p12, set an export password. - Base64-encode it:
base64 -i DeveloperIDApplication.p12 | pbcopy(macOSbase64auto-wraps; that's fine,base64 --decodein the script handles it either way). - In App Store Connect → Users and
Access → Integrations → App Store Connect API → generate a key with
the Developer role (notarization doesn't need Admin). Download the
.p8immediately — it's only downloadable once. Note its Key ID and Issuer ID (shown on that same page). - Base64-encode the
.p8the same way as step 3.
Set these as GitHub Actions repo secrets (Settings → Secrets and
variables → Actions → New repository secret, or via gh secret set <name>
run from your own machine — never paste secret material into a chat or
commit):
| Secret | Value |
|---|---|
MACOS_CERTIFICATE_P12_BASE64 |
output of step 3 |
MACOS_CERTIFICATE_PASSWORD |
the export password from step 2 |
MACOS_NOTARY_KEY_ID |
Key ID from step 4 |
MACOS_NOTARY_ISSUER_ID |
Issuer ID from step 4 |
MACOS_NOTARY_KEY_P8_BASE64 |
output of step 5 |
gh secret set MACOS_CERTIFICATE_P12_BASE64 < cert_base64.txt --repo YuyiChang/PLASMA
gh secret set MACOS_CERTIFICATE_PASSWORD --repo YuyiChang/PLASMA # prompts, hidden input
gh secret set MACOS_NOTARY_KEY_ID --repo YuyiChang/PLASMA
gh secret set MACOS_NOTARY_ISSUER_ID --repo YuyiChang/PLASMA
gh secret set MACOS_NOTARY_KEY_P8_BASE64 < notary_key_base64.txt --repo YuyiChang/PLASMA
No MACOS_SIGN_IDENTITY secret is needed — the script imports the
certificate into a throwaway keychain and reads the identity string
(security find-identity) straight out of it. No keychain-password secret
either — that keychain is created fresh and deleted at the end of every job
run, so its unlock password is just generated inline (openssl rand) and
never needs to be stored.
.github/macos_entitlements.plist grants the Hardened Runtime exceptions a
PyInstaller onedir bundle needs to actually run once signed (library
validation would otherwise block loading bundled numpy/scipy/liblsl, since
they're not signed by this project's Team ID) — see the comments in that
file before changing it.
Once this is live, two things become worth revisiting (not done as part
of turning signing on): the Homebrew cask's caveats text ("PLASMA is not
code-signed or notarized...") and its quarantine-clearing postflight_steps
both go stale for signed releases — harmless to leave (clearing quarantine
on an already-legitimate app is a no-op, not a problem), but worth cleaning
up once a signed release has actually shipped.
Codesigning (Windows, self-signed)¶
build-windows's "Codesign raw exe" / "Codesign installer" steps
(.github/codesign_windows.ps1) sign PLASMA_Windows_x64.exe and
PLASMA_Windows_x64_Setup.exe with signtool — but only if two repo
secrets are set. Until they are, this no-ops and every Windows asset ships
unsigned exactly as before.
Important — set expectations before setting this up: unlike the macOS notarization above, there is no free path to a CA-issued Windows certificate, and a self-signed certificate does not stop SmartScreen's "Windows protected your PC" warning — SmartScreen trusts a signature via Microsoft's reputation system, which only recognizes certificates from a handful of trusted CAs (building reputation over many downloads for a standard OV cert, or instantly for a paid EV cert). A self-signed cert is invisible to that system entirely. What it does buy: tamper-evidence, a consistent signer identity across releases (so an update can be verified as "from the same publisher" even though that publisher is unverified), and the exact signing plumbing a real purchased cert would slot into later — just swap the two secrets below, no pipeline changes.
One-time setup — generate a self-signed code-signing certificate
(anywhere with openssl, e.g. this same macOS machine; it doesn't need to
be done on Windows):
openssl req -x509 -newkey rsa:2048 -keyout key.pem -out cert.pem -days 3650 -nodes \
-subj "/CN=PLASMA/O=YuyiChang" -addext "extendedKeyUsage=codeSigning"
openssl pkcs12 -export -out plasma_selfsigned.pfx -inkey key.pem -in cert.pem \
-passout pass:<choose a password> -legacy # -legacy: signtool needs RC2/3DES-era PKCS12, not OpenSSL 3's new default
base64 -i plasma_selfsigned.pfx -o pfx_base64.txt
rm key.pem cert.pem # don't leave the private key sitting on disk afterward
Set these as GitHub Actions repo secrets (never paste secret material into a chat or commit):
gh secret set WINDOWS_CERTIFICATE_PFX_BASE64 < pfx_base64.txt --repo YuyiChang/PLASMA
gh secret set WINDOWS_CERTIFICATE_PASSWORD --repo YuyiChang/PLASMA # prompts, hidden input
rm pfx_base64.txt plasma_selfsigned.pfx
No verification step runs after signing (unlike the macOS script's
codesign --verify) — signtool verify /pa checks the signature chains to
a trusted root, which a self-signed cert never does by design, so that
check would always "fail" even on a correct signature. The signtool sign
call's own exit code is the real check.
Homebrew tap (yuyichang/homebrew-plasma)¶
Separate repo. Self-automated via
.github/workflows/bump-plasma.yml in that repo (not this one): a daily
scheduled job (plus workflow_dispatch for an immediate manual run right
after cutting a release) checks PLASMA's /releases/latest — which already
excludes prereleases, so it naturally skips the rolling nightly tag — and,
if the version differs from what Casks/plasma.rb currently has, downloads
that release's PLASMA_MacOS_arm64.app.zip, computes its sha256, and opens
a PR bumping both. Merge (or close) that PR by hand; nothing pushes to
main automatically. Needs the tap repo's Settings → Actions → General →
"Allow GitHub Actions to create and approve pull requests" turned on (it
is), or the Open PR step fails.
This intentionally runs as a job inside homebrew-plasma, using that
repo's own default GITHUB_TOKEN (scoped to itself, via permissions:
contents: write / pull-requests: write) — not a job in this repo's CI
reaching across to bump the tap. The latter would need a cross-repo PAT
stored as a secret here with write access to a different public repo, a
meaningfully bigger credential blast radius for no real benefit; running the
poll the other direction avoids needing any extra credential at all.
If you ever need to bump it by hand instead (e.g. the scheduled job is broken, or you don't want to wait for it):
brew bump-cask-pr --version <new-version> --write-only yuyichang/plasma/plasma
git -C "$(brew --repo yuyichang/plasma)" diff # review, then commit + push
brew bump-cask-pr downloads the release's PLASMA_MacOS_arm64.app.zip
itself and computes the sha256 — you don't need to copy it from the
.sha256 file GitHub Actions attaches (that file exists for manual
verification, e.g. by someone auditing the cask before merging). Gotcha:
it finds the old sha256 by searching for its literal (lowercased) text and
replacing it — only works once the cask already carries a real, valid
sha256; it can't fill in a non-hex placeholder like "REPLACE_WITH_SHA256".
Only relevant the very first time the tap gets a real release.
One-time note, already resolved: the first onedir-based release (v2.2.3)
needed a manual fix to the cask's binary stanza — changed from
.../Contents/Resources/plasma-bin to
.../Contents/Resources/PLASMA_MacOS_arm64/PLASMA_MacOS_arm64 (the onedir
folder is copied into Contents/Resources/ keeping its own name now,
instead of being flattened to a single plasma-bin file) — since neither
bump-cask-pr nor the scheduled-bump workflow touch anything but
version/sha256. That path is stable going forward, so this shouldn't
recur. (Considered switching the cask to consume the .dmg directly via
Homebrew Cask's native .dmg handling instead of the hand-rolled
.app.zip — deferred; .app.zip stays the cask's source for now.)
plasma@nightly — no bump needed, ever¶
packaging/homebrew/plasma@nightly.rb (copy verbatim into the tap's
Casks/plasma@nightly.rb once, then leave it alone) tracks the rolling
nightly pre-release tag the nightly.yml dispatcher and build.yml republish daily.
Unlike plasma.rb, its url points at the fixed nightly tag rather than a
version-substituted one, and it uses version :latest / sha256 :no_check
instead of a pinned checksum — so there is no per-release bump step for this
cask at all. The tradeoff is the one brew bump-cask-pr normally buys you:
Homebrew can't tell when a new nightly has landed, so users have to
brew reinstall --cask plasma@nightly themselves to pick one up (documented
in the cask's own caveats).
Scoop bucket (YuyiChang/scoop-plasma)¶
Windows counterpart of the Homebrew tap: a separate repo holding
bucket/plasma.json and bucket/plasma-nightly.json (sources kept in
packaging/scoop/ here). Installs PLASMA_Windows_x64.zip (the raw onedir
folder), not the Inno Setup installer. The point is SmartScreen: its
"Windows protected your PC" prompt keys off the Mark-of-the-Web a browser
stamps on downloads, and Scoop downloads the zip itself, so the unsigned
exe launches with no prompt. Code signing isn't involved at all. (It does
not help against Smart App Control or AppLocker/WDAC, which judge the
binary itself.)
Self-automated the same way as the tap:
.github/workflows/bump-plasma.yml in that repo polls
/releases/latest daily (plus workflow_dispatch), and if the version
differs from bucket/plasma.json's, downloads that release's
PLASMA_Windows_x64.zip, computes its sha256, and opens a PR rewriting
version / architecture.64bit.url / architecture.64bit.hash with jq.
Merge (or close) by hand. Same own-GITHUB_TOKEN, no-cross-repo-PAT
reasoning as the tap. Needs the repo's Settings → Actions → General →
"Allow GitHub Actions to create and approve pull requests" turned on (it
is), or the Open PR step fails.
The manifest also carries checkver / autoupdate, so a by-hand bump on a
Windows machine with Scoop installed is:
& "$(scoop prefix scoop)\bin\checkver.ps1" -App plasma -Dir .\bucket -Update
Keep packaging/scoop/plasma.json in sync if you change anything other
than version/url/hash (the bump workflow only touches those three in the
bucket copy).
plasma-nightly — no bump needed, ever¶
"version": "nightly" with no hash is Scoop's built-in convention for
this: it skips hash verification and treats the install as
nightly-<yyyyMMdd>, so scoop update plasma-nightly re-downloads the
fixed nightly-tag URL at most once a day. Nothing to bump per release.