openpencil/.github/workflows/rust-release.yml

968 lines
41 KiB
YAML

name: Rust release artifacts
# Triggered on tag push (v*) — builds release binaries across desktop targets,
# packages real installers per platform, builds the web Docker image and SDK
# tarballs, publishes the web SDK packages to npm, publishes a GitHub
# prerelease, then updates the Homebrew tap and Scoop bucket.
#
# Artifact matrix (per platform; <v> = tag version, arch token = x64/arm64 to
# match electron-builder's `${productName}-${version}-${arch}-…` convention
# from apps/desktop/electron-builder.yml):
# macOS OpenPencil-<v>-<arch>-mac.dmg notarized final DMG containing
# a notarized .app from
# scripts/bundle-macos.sh
# (.op/.pen/.fig associations in
# Info.plist, op CLI embedded in
# Contents/MacOS; Developer ID
# signed/notarized when secrets
# exist, ad-hoc signed locally)
# op-cli-<label>.tar.gz standalone CLI
# Windows OpenPencil-<v>-<arch>-win-setup.exe NSIS installer (Start-Menu +
# desktop shortcuts, HKCR
# .op/.pen ProgID, uninstaller,
# bundles op.exe)
# openpencil-desktop-<label>.zip portable (kept)
# op-cli-<label>.zip standalone CLI
# Linux OpenPencil-<v>-<arch>-linux.AppImage both arches (appimagetool
# 1.9.1 ships native x86_64 AND
# aarch64 builds)
# OpenPencil-<v>-<arch>-linux.deb .desktop + shared-mime-info
# registration of .op/.pen,
# bundles usr/bin/op
# openpencil-desktop-<label>.tar.gz raw binary (kept)
# op-cli-<label>.tar.gz standalone CLI
#
# Branding: productName "OpenPencil" everywhere (parity with
# electron-builder.yml). The macOS bundle id stays com.zseven-w.openpencil
# (the Rust shell identity already registered with LaunchServices via
# scripts/bundle-macos.sh + op-host-desktop bundle metadata) — intentionally
# NOT electron's dev.openpencil.app, so the Electron and Rust apps can
# coexist without fighting over file-association ownership.
#
# ─── Local-verification caveats (2026-06-10, macOS dev machine) ────────────
# Verified locally:
# * bash -n on scripts/bundle-macos.sh, package-deb.sh, package-appimage.sh,
# package-linux-common.sh
# * YAML parse of this file
# * package-deb.sh --layout-only and package-appimage.sh layout dry-runs
# with stub binaries (payload tree, control file, desktop entry, mime XML)
# * appimagetool 1.9.1 pinned release assets (x86_64 + aarch64) exist
# (checked via GitHub API); cargo-bundle `--target` flag confirmed
# against upstream source (present since v0.9.0)
# NOT verifiable locally — needs a real tag-push CI run:
# * makensis compile of scripts/package-windows.nsi (no makensis on macOS;
# CI installs NSIS on windows-latest before packaging)
# * actual AppImage assembly (appimagetool is a Linux ELF; it also
# downloads its type2-runtime from GitHub at pack time — needs network)
# * dpkg-deb --build (no dpkg locally; only the layout path was dry-run)
# * cargo-bundle bootstrap + `--target` bundling + hdiutil DMG creation on
# the GitHub macOS runner
# * installer smoke tests (NSIS install/uninstall + HKCR assoc, deb
# postinst mime refresh, AppImage launch, DMG mount + Gatekeeper)
#
# The `build` job builds the real `op-host-desktop` crate (winit + skia-safe
# runner that drives the Rust editor); its shipped executable keeps the
# stable `openpencil-desktop` name. The `op` CLI (crates/op-cli, binary name
# `op`) is built in the same cargo invocation. The web Docker job builds the
# Rust web host from `Dockerfile.web-rust`, and the SDK job packages the
# wasm-backed `packages/op-web-sdk*` workspace tarballs.
#
# The standalone `.github/workflows/wasm-bundle-build.yml` still gates the
# CanvasKit web bundle on push/PR. This release workflow only assembles the
# tagged, publishable artifacts.
on:
push:
tags: ['v*']
workflow_dispatch:
permissions:
contents: write
packages: write
jobs:
build:
name: ${{ matrix.label }}
runs-on: ${{ matrix.runner }}
if: github.event_name == 'workflow_dispatch' || startsWith(github.ref, 'refs/tags/v')
env:
HAS_APPLE_CERTIFICATE: ${{ secrets.CSC_LINK != '' && secrets.CSC_KEY_PASSWORD != '' && secrets.APPLE_ID != '' && secrets.APPLE_TEAM_ID != '' && secrets.APPLE_APP_SPECIFIC_PASSWORD != '' && 'true' || 'false' }}
HAS_WINDOWS_CERTIFICATE: ${{ secrets.WIN_CSC_LINK != '' && secrets.WIN_CSC_KEY_PASSWORD != '' && 'true' || 'false' }}
TAP_GITHUB_TOKEN: ${{ secrets.TAP_GITHUB_TOKEN }}
strategy:
fail-fast: false
matrix:
include:
- label: macos-aarch64
runner: macos-latest
target: aarch64-apple-darwin
archive: tar.gz
arch: arm64
# macos-13 (Intel) deprecated; cross-compile x86_64-apple-darwin
# from Apple Silicon (cargo supports cross-compile to host's other
# arch out of the box, no `cross` needed).
- label: macos-x86_64
runner: macos-latest
target: x86_64-apple-darwin
archive: tar.gz
arch: x64
- label: linux-x86_64
runner: ubuntu-latest
target: x86_64-unknown-linux-gnu
archive: tar.gz
arch: x64
# Native ARM64 hosted runner — no `cross`. The cross 0.2.x aarch64
# image (Ubuntu 16.04 / GCC 5 / FreeType 2.6) is too old to link the
# modern skia that skia-bindings builds.
- label: linux-aarch64
runner: ubuntu-24.04-arm
target: aarch64-unknown-linux-gnu
archive: tar.gz
arch: arm64
- label: windows-x86_64
runner: windows-latest
target: x86_64-pc-windows-msvc
archive: zip
arch: x64
# Windows ARM64 — cargo cross-compile from x86_64 windows runner.
# The NSIS installer stub stays x86 (runs under emulation on
# Windows-on-ARM); the installed payload is native aarch64.
- label: windows-aarch64
runner: windows-latest
target: aarch64-pc-windows-msvc
archive: zip
arch: arm64
steps:
- uses: actions/checkout@v4
with:
submodules: recursive
- uses: dtolnay/rust-toolchain@stable
with:
toolchain: '1.94'
targets: ${{ matrix.target }}
- uses: Swatinem/rust-cache@v2
with:
key: release-${{ matrix.target }}
- name: Install Linux GL/EGL prereqs
if: runner.os == 'Linux'
run: |
sudo apt-get update
sudo apt-get install -y \
libxkbcommon-dev libxkbcommon-x11-dev \
libwayland-dev libxcb-render0-dev libxcb-shape0-dev libxcb-xfixes0-dev \
libegl1-mesa-dev libgles2-mesa-dev libgbm-dev \
libfreetype-dev libfontconfig1-dev
- name: Compute release version
shell: bash
run: |
# Tag pushes (vX.Y.Z) drive the artifact version; workflow_dispatch
# falls back to [workspace.package].version in the root Cargo.toml.
if [[ "$GITHUB_REF" == refs/tags/v* ]]; then
echo "OP_VERSION=${GITHUB_REF_NAME#v}" >> "$GITHUB_ENV"
else
ver="$(awk -F'"' '/^\[workspace\.package\]/{f=1} f && /^version/{print $2; exit}' Cargo.toml)"
echo "OP_VERSION=${ver:-0.0.0}" >> "$GITHUB_ENV"
fi
- name: Build (host)
run: cargo build -p op-host-desktop -p op-cli --target ${{ matrix.target }} --release
- name: Package archives (unix)
if: matrix.archive == 'tar.gz'
shell: bash
run: |
cd target/${{ matrix.target }}/release
if [ "$RUNNER_OS" != "macOS" ]; then
tar czf ../../../openpencil-desktop-${{ matrix.label }}.tar.gz openpencil-desktop
fi
tar czf ../../../op-cli-${{ matrix.label }}.tar.gz op
- name: Package archives (windows)
if: matrix.archive == 'zip'
shell: pwsh
run: |
Compress-Archive `
-Path target\${{ matrix.target }}\release\openpencil-desktop.exe `
-DestinationPath openpencil-desktop-${{ matrix.label }}.zip
Compress-Archive `
-Path target\${{ matrix.target }}\release\op.exe `
-DestinationPath op-cli-${{ matrix.label }}.zip
- name: Import codesign certificate (macos)
if: runner.os == 'macOS' && env.HAS_APPLE_CERTIFICATE == 'true'
uses: apple-actions/import-codesign-certs@v3
with:
p12-file-base64: ${{ secrets.CSC_LINK }}
p12-password: ${{ secrets.CSC_KEY_PASSWORD }}
- name: Resolve macOS signing identity
if: runner.os == 'macOS' && env.HAS_APPLE_CERTIFICATE == 'true'
shell: bash
run: |
set -euo pipefail
identity="$(security find-identity -v -p codesigning | awk -F'"' '/Developer ID Application/ {print $2; exit}')"
if [ -z "$identity" ]; then
security find-identity -v -p codesigning
echo "::error::Developer ID Application identity not found in imported keychain"
exit 1
fi
echo "MACOS_SIGN_IDENTITY=$identity" >> "$GITHUB_ENV"
- name: Bundle macOS app
if: runner.os == 'macOS'
shell: bash
env:
OPENPENCIL_VERSION: ${{ env.OP_VERSION }}
OPENPENCIL_TARGET: ${{ matrix.target }}
# Hand the already-built binaries to the bundle script: it skips its
# own cargo build and overwrites the bundled executable with exactly
# what this workflow built (and embeds the op CLI in Contents/MacOS).
OPENPENCIL_BINARY: ${{ github.workspace }}/target/${{ matrix.target }}/release/openpencil-desktop
OPENPENCIL_CLI_BINARY: ${{ github.workspace }}/target/${{ matrix.target }}/release/op
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
run: |
if [ "$HAS_APPLE_CERTIFICATE" = "true" ]; then
: "${MACOS_SIGN_IDENTITY:?MACOS_SIGN_IDENTITY was not resolved}"
export MACOS_SIGN_IDENTITY
fi
bash scripts/bundle-macos.sh
- name: Notarize macOS app
if: runner.os == 'macOS' && env.HAS_APPLE_CERTIFICATE == 'true'
shell: bash
env:
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
APPLE_ID: ${{ secrets.APPLE_ID }}
APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }}
run: |
set -euo pipefail
APP="target/${{ matrix.target }}/release/bundle/osx/OpenPencil.app"
APP_ZIP="OpenPencil-$OP_VERSION-${{ matrix.arch }}-mac-app.zip"
ditto -c -k --keepParent "$APP" "$APP_ZIP"
notary_log="$(mktemp)"
set +e
xcrun notarytool submit "$APP_ZIP" \
--apple-id "$APPLE_ID" \
--team-id "$APPLE_TEAM_ID" \
--password "$APPLE_APP_SPECIFIC_PASSWORD" \
--wait 2>&1 | tee "$notary_log"
notary_status="${PIPESTATUS[0]}"
set -e
submission_id="$(awk '/id: / { print $2; exit }' "$notary_log")"
if [ -n "$submission_id" ]; then
xcrun notarytool log "$submission_id" \
--apple-id "$APPLE_ID" \
--team-id "$APPLE_TEAM_ID" \
--password "$APPLE_APP_SPECIFIC_PASSWORD" || true
fi
if [ "$notary_status" -ne 0 ] || grep -q "status: Invalid" "$notary_log"; then
echo "::error::macOS app notarization failed"
exit 1
fi
xcrun stapler staple "$APP"
xcrun stapler validate "$APP"
- name: Package DMG (macos)
if: runner.os == 'macOS'
shell: bash
run: |
set -euo pipefail
APP="target/${{ matrix.target }}/release/bundle/osx/OpenPencil.app"
DMG="OpenPencil-$OP_VERSION-${{ matrix.arch }}-mac.dmg"
STAGE="$(mktemp -d)"
ditto "$APP" "$STAGE/OpenPencil.app"
if [ "$HAS_APPLE_CERTIFICATE" = "true" ]; then
xcrun stapler validate "$STAGE/OpenPencil.app"
fi
ln -s /Applications "$STAGE/Applications"
# HFS+ instead of the APFS default: mountable on every macOS the
# binary itself supports.
hdiutil create \
-volname "OpenPencil $OP_VERSION" \
-srcfolder "$STAGE" \
-fs HFS+ -format UDZO -ov \
"$DMG"
if [ "$HAS_APPLE_CERTIFICATE" = "true" ]; then
codesign --force --timestamp --sign "$MACOS_SIGN_IDENTITY" "$DMG"
codesign --verify --verbose "$DMG"
fi
- name: Notarize DMG (macos)
if: runner.os == 'macOS' && env.HAS_APPLE_CERTIFICATE == 'true'
shell: bash
env:
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
APPLE_ID: ${{ secrets.APPLE_ID }}
APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }}
run: |
set -euo pipefail
DMG="OpenPencil-$OP_VERSION-${{ matrix.arch }}-mac.dmg"
notary_log="$(mktemp)"
set +e
xcrun notarytool submit "$DMG" \
--apple-id "$APPLE_ID" \
--team-id "$APPLE_TEAM_ID" \
--password "$APPLE_APP_SPECIFIC_PASSWORD" \
--wait 2>&1 | tee "$notary_log"
notary_status="${PIPESTATUS[0]}"
set -e
submission_id="$(awk '/id: / { print $2; exit }' "$notary_log")"
if [ -n "$submission_id" ]; then
xcrun notarytool log "$submission_id" \
--apple-id "$APPLE_ID" \
--team-id "$APPLE_TEAM_ID" \
--password "$APPLE_APP_SPECIFIC_PASSWORD" || true
fi
if [ "$notary_status" -ne 0 ] || grep -q "status: Invalid" "$notary_log"; then
echo "::error::macOS DMG notarization failed"
exit 1
fi
xcrun stapler staple "$DMG"
xcrun stapler validate "$DMG"
# ─── Code-signing secret reference ─────────────────────────────────
# Each platform signing path is gated on its required secrets. Without
# them, releases ship ad-hoc-signed (macOS, via scripts/bundle-macos.sh)
# / unsigned (Windows); with them, CI performs Developer ID
# signing/notarization for macOS and Authenticode signing for Windows.
#
# Required macOS secrets (Developer ID + notarization):
# CSC_LINK base64 of the Developer ID Application
# .p12 export
# CSC_KEY_PASSWORD password for that .p12
# APPLE_TEAM_ID 10-char Apple Developer Team ID
# APPLE_ID Apple ID email used for notarization
# APPLE_APP_SPECIFIC_PASSWORD app-specific password for notarytool
#
# Required Windows secrets (Authenticode):
# WIN_CSC_LINK base64/data URL/URL of the code-signing
# .pfx
# WIN_CSC_KEY_PASSWORD password for that .pfx
#
# Optional downstream publish secrets consumed by release packaging:
# NPM_TOKEN npm registry token for SDK publishing
# TAP_GITHUB_TOKEN GitHub token for tap/formula publishing
- name: Install NSIS (windows)
if: runner.os == 'Windows'
shell: pwsh
run: |
choco install nsis -y --no-progress
$nsisRoots = @(
"${env:ProgramFiles(x86)}\NSIS",
"$env:ProgramFiles\NSIS"
)
$makensis = $null
foreach ($root in $nsisRoots) {
$candidate = Join-Path $root "makensis.exe"
if (Test-Path $candidate) {
$makensis = $candidate
break
}
}
if (-not $makensis) {
throw "makensis.exe not found after NSIS install"
}
Split-Path $makensis | Out-File -FilePath $env:GITHUB_PATH -Append
& $makensis /VERSION
- name: Package NSIS installer (windows)
if: runner.os == 'Windows'
shell: pwsh
run: |
# Defines are absolute because NSIS resolves relative paths against
# the .nsi's own directory (scripts/).
makensis `
"/DVERSION=$env:OP_VERSION" `
"/DARCH=${{ matrix.arch }}" `
"/DBIN_DIR=$env:GITHUB_WORKSPACE\target\${{ matrix.target }}\release" `
"/DICON_FILE=$env:GITHUB_WORKSPACE\crates\op-host-desktop\assets\icon.ico" `
"/DOUT_FILE=$env:GITHUB_WORKSPACE\OpenPencil-$env:OP_VERSION-${{ matrix.arch }}-win-setup.exe" `
scripts\package-windows.nsi
# Authenticode signing scaffolding (NO-OP until WINDOWS_CERT_BASE64
# exists — see the secrets comment block in the macOS section above).
# Until then the NSIS installer ships unsigned (current behavior).
- name: Sign NSIS installer (windows)
if: runner.os == 'Windows' && env.HAS_WINDOWS_CERTIFICATE == 'true'
shell: pwsh
env:
WIN_CSC_LINK: ${{ secrets.WIN_CSC_LINK }}
WIN_CSC_KEY_PASSWORD: ${{ secrets.WIN_CSC_KEY_PASSWORD }}
run: |
# Materialize the .pfx from a base64/data-url/url secret into a temp file.
$pfx = Join-Path $env:RUNNER_TEMP "codesign.pfx"
$link = $env:WIN_CSC_LINK
if ($link -match '^https?://') {
Invoke-WebRequest -Uri $link -OutFile $pfx
} else {
if ($link -match '^data:.*;base64,(.+)$') {
$link = $Matches[1]
}
[IO.File]::WriteAllBytes($pfx, [Convert]::FromBase64String($link))
}
$exe = "OpenPencil-$env:OP_VERSION-${{ matrix.arch }}-win-setup.exe"
# signtool ships with the Windows SDK preinstalled on
# windows-latest. RFC-3161 timestamp so signatures outlive the
# cert's validity window.
$signtool = (Get-ChildItem "C:\Program Files (x86)\Windows Kits\10\bin\*\x64\signtool.exe" |
Sort-Object FullName -Descending | Select-Object -First 1).FullName
& $signtool sign `
/f $pfx `
/p $env:WIN_CSC_KEY_PASSWORD `
/fd SHA256 `
/tr http://timestamp.digicert.com `
/td SHA256 `
$exe
Remove-Item $pfx -Force
- name: Package .deb (linux)
if: runner.os == 'Linux'
shell: bash
run: |
case "${{ matrix.target }}" in
x86_64-*) DEB_ARCH=amd64 ;;
aarch64-*) DEB_ARCH=arm64 ;;
*) echo "unmapped deb arch for ${{ matrix.target }}" >&2; exit 1 ;;
esac
bash scripts/package-deb.sh \
--desktop-bin "target/${{ matrix.target }}/release/openpencil-desktop" \
--cli-bin "target/${{ matrix.target }}/release/op" \
--icon crates/op-host-desktop/assets/icon.png \
--version "$OP_VERSION" \
--arch "$DEB_ARCH" \
--out-dir .
- name: Package AppImage (linux)
if: runner.os == 'Linux'
shell: bash
run: |
case "${{ matrix.target }}" in
x86_64-*) AI_ARCH=x86_64 ;;
aarch64-*) AI_ARCH=aarch64 ;;
*) echo "unmapped AppImage arch for ${{ matrix.target }}" >&2; exit 1 ;;
esac
# appimagetool pinned by URL (release 1.9.1 ships native builds for
# both runner arches, so each matrix leg packs natively). NOTE:
# appimagetool 1.9.x downloads the matching static type2-runtime
# from GitHub at pack time — the runner needs network access.
curl -fsSL --retry 3 -o /tmp/appimagetool \
"https://github.com/AppImage/appimagetool/releases/download/1.9.1/appimagetool-${AI_ARCH}.AppImage"
chmod +x /tmp/appimagetool
bash scripts/package-appimage.sh \
--desktop-bin "target/${{ matrix.target }}/release/openpencil-desktop" \
--cli-bin "target/${{ matrix.target }}/release/op" \
--icon crates/op-host-desktop/assets/icon.png \
--version "$OP_VERSION" \
--arch "$AI_ARCH" \
--tool /tmp/appimagetool \
--out-dir .
- name: Upload artifact
uses: actions/upload-artifact@v4
with:
name: openpencil-desktop-${{ matrix.label }}
path: |
openpencil-desktop-${{ matrix.label }}.tar.gz
openpencil-desktop-${{ matrix.label }}.zip
op-cli-${{ matrix.label }}.tar.gz
op-cli-${{ matrix.label }}.zip
OpenPencil-*-mac.dmg
OpenPencil-*-win-setup.exe
OpenPencil-*-linux.AppImage
OpenPencil-*-linux.deb
if-no-files-found: ignore
web-docker:
name: Build and push web Docker image
runs-on: ubuntu-latest
if: startsWith(github.ref, 'refs/tags/v')
outputs:
image: ${{ steps.meta.outputs.image }}
tag: ${{ steps.meta.outputs.tag }}
digest: ${{ steps.build.outputs.digest }}
steps:
- uses: actions/checkout@v4
with:
submodules: recursive
- uses: docker/setup-buildx-action@v3
- name: Log in to GHCR
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ github.token }}
- name: Compute image metadata
id: meta
shell: bash
run: |
version="${GITHUB_REF_NAME#v}"
image="ghcr.io/${GITHUB_REPOSITORY_OWNER,,}/openpencil-web"
tag="${image}:v${version}"
echo "image=${image}" >> "$GITHUB_OUTPUT"
echo "tag=${tag}" >> "$GITHUB_OUTPUT"
- name: Build and push
id: build
uses: docker/build-push-action@v6
with:
context: .
file: Dockerfile.web-rust
push: true
tags: ${{ steps.meta.outputs.tag }}
sdk-packages:
name: Build and publish web SDK npm packages
runs-on: ubuntu-latest
if: startsWith(github.ref, 'refs/tags/v')
env:
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
steps:
- uses: actions/checkout@v4
with:
submodules: recursive
- uses: dtolnay/rust-toolchain@stable
with:
toolchain: '1.94'
targets: wasm32-unknown-unknown
- uses: Swatinem/rust-cache@v2
with:
shared-key: web-sdk-release
- uses: oven-sh/setup-bun@v2
- name: Install wasm-bindgen-cli (pinned to Cargo.lock)
shell: bash
run: |
version="$(awk '/^name = "wasm-bindgen"$/{found=1; next} found && /^version = /{gsub(/[" ]/,"",$3); print $3; exit}' Cargo.lock)"
if [ -z "$version" ]; then
echo "::error::could not resolve wasm-bindgen version from Cargo.lock"
exit 1
fi
cargo install wasm-bindgen-cli --version "$version" --locked
- name: Install binaryen
run: |
BINARYEN_VERSION=version_123
curl -fsSL \
"https://github.com/WebAssembly/binaryen/releases/download/${BINARYEN_VERSION}/binaryen-${BINARYEN_VERSION}-x86_64-linux.tar.gz" \
| tar -xz
echo "${GITHUB_WORKSPACE}/binaryen-${BINARYEN_VERSION}/bin" >> "$GITHUB_PATH"
- name: Install package workspace dependencies
working-directory: packages
run: bun install --frozen-lockfile
- name: Configure npm auth
shell: bash
run: |
set -euo pipefail
if [ -z "${NPM_TOKEN:-}" ]; then
echo "::error::NPM_TOKEN is required to publish web SDK packages"
exit 1
fi
echo "::notice::NPM_TOKEN must be scoped for @zseven-w packages and have bypass 2FA enabled"
printf '//registry.npmjs.org/:_authToken=%s\n' "$NPM_TOKEN" > ~/.npmrc
npm whoami
- name: Build wasm-backed SDK packages
shell: bash
run: |
set -euo pipefail
cd packages/op-web-sdk
bun run sync-wasm
bun run build
cd ../op-web-sdk-react
bun run build
cd ../op-web-sdk-vue
bun run build
- name: Pack npm tarballs
shell: bash
run: |
set -euo pipefail
mkdir -p sdk-packages
for pkg in op-web-sdk op-web-sdk-react op-web-sdk-vue; do
(cd "packages/${pkg}" && bun pm pack --destination "${GITHUB_WORKSPACE}/sdk-packages")
done
ls -la sdk-packages
- name: Publish npm packages
shell: bash
run: |
set -euo pipefail
version="${GITHUB_REF_NAME#v}"
publish_pkg() {
local name="$1"
local tarball="$2"
local view_output
if view_output="$(npm view "${name}@${version}" version 2>&1)"; then
echo "::notice::${name}@${version} is already published; skipping"
return
fi
if ! grep -Eq 'E404|404 Not Found|is not in this registry' <<<"$view_output"; then
printf '%s\n' "$view_output"
echo "::error::failed to check npm package ${name}@${version}"
exit 1
fi
npm publish "./$tarball" --access public --tag next
}
publish_pkg "@zseven-w/op-web-sdk" "sdk-packages/zseven-w-op-web-sdk-${version}.tgz"
publish_pkg "@zseven-w/op-web-sdk-react" "sdk-packages/zseven-w-op-web-sdk-react-${version}.tgz"
publish_pkg "@zseven-w/op-web-sdk-vue" "sdk-packages/zseven-w-op-web-sdk-vue-${version}.tgz"
- name: Upload SDK tarballs
uses: actions/upload-artifact@v4
with:
name: openpencil-web-sdk-packages
path: sdk-packages/*.tgz
if-no-files-found: error
release-draft:
name: Create / update GitHub prerelease
needs: [build, web-docker, sdk-packages]
runs-on: ubuntu-latest
if: startsWith(github.ref, 'refs/tags/v')
env:
TAP_GITHUB_TOKEN: ${{ secrets.TAP_GITHUB_TOKEN }}
steps:
- uses: actions/checkout@v4
- uses: actions/download-artifact@v4
with:
path: dist
pattern: openpencil-*
- name: Flatten artifacts
shell: bash
run: |
set -euo pipefail
mkdir -p release-files
find dist -type f \
\( -name "*.tar.gz" -o -name "*.zip" -o -name "*.dmg" \
-o -name "*.exe" -o -name "*.AppImage" -o -name "*.deb" \
-o -name "*.tgz" \) \
-exec cp {} release-files/ \;
ls -la release-files
shopt -s nullglob
native=(release-files/OpenPencil-* release-files/openpencil-desktop-*)
cli=(release-files/op-cli-*)
sdk=(release-files/*.tgz)
if [ "${#native[@]}" -eq 0 ]; then
echo "::error::missing native desktop artifacts"
exit 1
fi
if [ "${#cli[@]}" -eq 0 ]; then
echo "::error::missing op-cli artifacts"
exit 1
fi
if [ "${#sdk[@]}" -ne 3 ]; then
echo "::error::expected 3 SDK tarballs, found ${#sdk[@]}"
exit 1
fi
- name: Generate CLI install scripts
shell: bash
run: |
set -euo pipefail
tag="$GITHUB_REF_NAME"
version="${tag#v}"
sha_file() {
sha256sum "release-files/$1" | awk '{print $1}'
}
cli_mac_arm_sha="$(sha_file "op-cli-macos-aarch64.tar.gz")"
cli_mac_x64_sha="$(sha_file "op-cli-macos-x86_64.tar.gz")"
cli_linux_arm_sha="$(sha_file "op-cli-linux-aarch64.tar.gz")"
cli_linux_x64_sha="$(sha_file "op-cli-linux-x86_64.tar.gz")"
cli_win_arm_sha="$(sha_file "op-cli-windows-aarch64.zip")"
cli_win_x64_sha="$(sha_file "op-cli-windows-x86_64.zip")"
cp scripts/install-op.sh release-files/install-op.sh
cp scripts/install-op.ps1 release-files/install-op.ps1
OP_INSTALLER_VERSION="$version" \
OP_SHA_MACOS_AARCH64="$cli_mac_arm_sha" \
OP_SHA_MACOS_X86_64="$cli_mac_x64_sha" \
OP_SHA_LINUX_AARCH64="$cli_linux_arm_sha" \
OP_SHA_LINUX_X86_64="$cli_linux_x64_sha" \
perl -0pi -e '
s/^DEFAULT_OP_VERSION=.*/DEFAULT_OP_VERSION="$ENV{OP_INSTALLER_VERSION}"/m;
s/^DEFAULT_SHA_MACOS_AARCH64=.*/DEFAULT_SHA_MACOS_AARCH64="$ENV{OP_SHA_MACOS_AARCH64}"/m;
s/^DEFAULT_SHA_MACOS_X86_64=.*/DEFAULT_SHA_MACOS_X86_64="$ENV{OP_SHA_MACOS_X86_64}"/m;
s/^DEFAULT_SHA_LINUX_AARCH64=.*/DEFAULT_SHA_LINUX_AARCH64="$ENV{OP_SHA_LINUX_AARCH64}"/m;
s/^DEFAULT_SHA_LINUX_X86_64=.*/DEFAULT_SHA_LINUX_X86_64="$ENV{OP_SHA_LINUX_X86_64}"/m;
' release-files/install-op.sh
OP_INSTALLER_VERSION="$version" \
OP_SHA_WINDOWS_AARCH64="$cli_win_arm_sha" \
OP_SHA_WINDOWS_X86_64="$cli_win_x64_sha" \
perl -0pi -e '
s/^\$DefaultOpVersion = .*/\$DefaultOpVersion = "$ENV{OP_INSTALLER_VERSION}"/m;
s/^\$DefaultShaWindowsAarch64 = .*/\$DefaultShaWindowsAarch64 = "$ENV{OP_SHA_WINDOWS_AARCH64}"/m;
s/^\$DefaultShaWindowsX86_64 = .*/\$DefaultShaWindowsX86_64 = "$ENV{OP_SHA_WINDOWS_X86_64}"/m;
' release-files/install-op.ps1
chmod +x release-files/install-op.sh
if grep -E 'DEFAULT_OP_VERSION=""|DEFAULT_SHA_(MACOS|LINUX)_[A-Z0-9_]+=""|\$Default(OpVersion|ShaWindows[A-Za-z0-9_]+) = ""' \
release-files/install-op.sh release-files/install-op.ps1; then
echo "::error::CLI install scripts still contain unstamped release metadata"
exit 1
fi
grep -q "$version" release-files/install-op.sh
grep -q "$version" release-files/install-op.ps1
- name: Prepare release body
shell: bash
run: |
set -euo pipefail
version="${GITHUB_REF_NAME#v}"
note="RELEASE_NOTES/v${version}.md"
if [ ! -f "$note" ]; then
echo "::error::missing release note: $note"
exit 1
fi
cp "$note" release-body.md
{
printf '\n## Web Docker Image\n\n'
printf -- '- Image: `%s`\n' '${{ needs.web-docker.outputs.tag }}'
printf -- '- Digest: `%s`\n' '${{ needs.web-docker.outputs.digest }}'
} >> release-body.md
- name: Create / update GitHub prerelease
uses: softprops/action-gh-release@v2
with:
draft: false
prerelease: true
make_latest: false
body_path: release-body.md
files: release-files/*
package-managers:
name: Update Homebrew tap and Scoop bucket
needs: [release-draft]
runs-on: ubuntu-latest
if: startsWith(github.ref, 'refs/tags/v')
env:
TAP_GITHUB_TOKEN: ${{ secrets.TAP_GITHUB_TOKEN }}
HOMEBREW_TAP_REPOSITORY: ${{ vars.HOMEBREW_TAP_REPOSITORY || 'ZSeven-W/homebrew-openpencil' }}
SCOOP_BUCKET_REPOSITORY: ${{ vars.SCOOP_BUCKET_REPOSITORY || 'ZSeven-W/scoop-openpencil' }}
steps:
- name: Validate package-manager token
shell: bash
run: |
set -euo pipefail
if [ -z "${TAP_GITHUB_TOKEN:-}" ]; then
echo "::error::TAP_GITHUB_TOKEN is required to update Homebrew and Scoop repositories"
exit 1
fi
- name: Download release assets and compute hashes
shell: bash
env:
GH_TOKEN: ${{ github.token }}
run: |
set -euo pipefail
version="${GITHUB_REF_NAME#v}"
mkdir -p release-assets
gh release download "$GITHUB_REF_NAME" \
--repo "$GITHUB_REPOSITORY" \
--dir release-assets \
--clobber
required=(
"OpenPencil-${version}-arm64-mac.dmg"
"OpenPencil-${version}-x64-mac.dmg"
"openpencil-desktop-windows-x86_64.zip"
"openpencil-desktop-windows-aarch64.zip"
"op-cli-macos-aarch64.tar.gz"
"op-cli-macos-x86_64.tar.gz"
"op-cli-linux-aarch64.tar.gz"
"op-cli-linux-x86_64.tar.gz"
"op-cli-windows-aarch64.zip"
"op-cli-windows-x86_64.zip"
)
for file in "${required[@]}"; do
if [ ! -f "release-assets/$file" ]; then
echo "::error::missing release asset for package-manager update: $file"
exit 1
fi
done
sha_file() {
sha256sum "release-assets/$1" | awk '{print $1}'
}
{
echo "OP_VERSION=$version"
echo "MAC_ARM_DMG_SHA=$(sha_file "OpenPencil-${version}-arm64-mac.dmg")"
echo "MAC_X64_DMG_SHA=$(sha_file "OpenPencil-${version}-x64-mac.dmg")"
echo "CLI_MAC_ARM_SHA=$(sha_file "op-cli-macos-aarch64.tar.gz")"
echo "CLI_MAC_X64_SHA=$(sha_file "op-cli-macos-x86_64.tar.gz")"
echo "CLI_LINUX_ARM_SHA=$(sha_file "op-cli-linux-aarch64.tar.gz")"
echo "CLI_LINUX_X64_SHA=$(sha_file "op-cli-linux-x86_64.tar.gz")"
echo "DESKTOP_WIN_ARM_SHA=$(sha_file "openpencil-desktop-windows-aarch64.zip")"
echo "DESKTOP_WIN_X64_SHA=$(sha_file "openpencil-desktop-windows-x86_64.zip")"
echo "CLI_WIN_ARM_SHA=$(sha_file "op-cli-windows-aarch64.zip")"
echo "CLI_WIN_X64_SHA=$(sha_file "op-cli-windows-x86_64.zip")"
} >> "$GITHUB_ENV"
- name: Checkout Homebrew tap
uses: actions/checkout@v4
with:
repository: ${{ env.HOMEBREW_TAP_REPOSITORY }}
token: ${{ env.TAP_GITHUB_TOKEN }}
path: homebrew-tap
- name: Update Homebrew cask and formula
shell: bash
run: |
set -euo pipefail
mkdir -p homebrew-tap/Casks homebrew-tap/Formula
cat > homebrew-tap/Casks/openpencil.rb <<EOF
cask "openpencil" do
version "${OP_VERSION}"
on_arm do
sha256 "${MAC_ARM_DMG_SHA}"
url "https://github.com/zseven-w/openpencil/releases/download/v#{version}/OpenPencil-#{version}-arm64-mac.dmg"
end
on_intel do
sha256 "${MAC_X64_DMG_SHA}"
url "https://github.com/zseven-w/openpencil/releases/download/v#{version}/OpenPencil-#{version}-x64-mac.dmg"
end
name "OpenPencil"
desc "Open-source AI-native vector design tool"
homepage "https://github.com/zseven-w/openpencil"
app "OpenPencil.app"
zap trash: [
"~/Library/Application Support/OpenPencil",
"~/Library/Preferences/com.zseven-w.openpencil.plist",
"~/Library/Preferences/dev.openpencil.app.plist",
"~/Library/Caches/com.zseven-w.openpencil",
"~/Library/Caches/dev.openpencil.app",
]
end
EOF
cat > homebrew-tap/Formula/op.rb <<EOF
class Op < Formula
desc "OpenPencil command-line tool"
homepage "https://github.com/zseven-w/openpencil"
version "${OP_VERSION}"
license "MIT"
on_macos do
if Hardware::CPU.arm?
url "https://github.com/zseven-w/openpencil/releases/download/v#{version}/op-cli-macos-aarch64.tar.gz"
sha256 "${CLI_MAC_ARM_SHA}"
else
url "https://github.com/zseven-w/openpencil/releases/download/v#{version}/op-cli-macos-x86_64.tar.gz"
sha256 "${CLI_MAC_X64_SHA}"
end
end
on_linux do
if Hardware::CPU.arm?
url "https://github.com/zseven-w/openpencil/releases/download/v#{version}/op-cli-linux-aarch64.tar.gz"
sha256 "${CLI_LINUX_ARM_SHA}"
else
url "https://github.com/zseven-w/openpencil/releases/download/v#{version}/op-cli-linux-x86_64.tar.gz"
sha256 "${CLI_LINUX_X64_SHA}"
end
end
def install
bin.install "op"
end
test do
assert_match version.to_s, shell_output("#{bin}/op --version")
end
end
EOF
ruby -c homebrew-tap/Casks/openpencil.rb
ruby -c homebrew-tap/Formula/op.rb
- name: Commit Homebrew updates
shell: bash
run: |
set -euo pipefail
cd homebrew-tap
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
git add Casks/openpencil.rb Formula/op.rb
if git diff --cached --quiet; then
echo "::notice::Homebrew tap already matches v${OP_VERSION}"
else
git commit -m "Update OpenPencil to v${OP_VERSION}"
git push
fi
- name: Checkout Scoop bucket
uses: actions/checkout@v4
with:
repository: ${{ env.SCOOP_BUCKET_REPOSITORY }}
token: ${{ env.TAP_GITHUB_TOKEN }}
path: scoop-bucket
- name: Update Scoop manifests
shell: bash
run: |
set -euo pipefail
mkdir -p scoop-bucket/bucket
cat > scoop-bucket/bucket/openpencil.json <<EOF
{
"version": "${OP_VERSION}",
"description": "Open-source AI-native vector design tool",
"homepage": "https://github.com/zseven-w/openpencil",
"license": "MIT",
"architecture": {
"64bit": {
"url": "https://github.com/zseven-w/openpencil/releases/download/v${OP_VERSION}/openpencil-desktop-windows-x86_64.zip",
"hash": "${DESKTOP_WIN_X64_SHA}"
},
"arm64": {
"url": "https://github.com/zseven-w/openpencil/releases/download/v${OP_VERSION}/openpencil-desktop-windows-aarch64.zip",
"hash": "${DESKTOP_WIN_ARM_SHA}"
}
},
"bin": "openpencil-desktop.exe",
"shortcuts": [["openpencil-desktop.exe", "OpenPencil"]],
"checkver": {
"github": "https://github.com/zseven-w/openpencil"
},
"autoupdate": {
"architecture": {
"64bit": {
"url": "https://github.com/zseven-w/openpencil/releases/download/v\$version/openpencil-desktop-windows-x86_64.zip"
},
"arm64": {
"url": "https://github.com/zseven-w/openpencil/releases/download/v\$version/openpencil-desktop-windows-aarch64.zip"
}
}
}
}
EOF
cat > scoop-bucket/bucket/op.json <<EOF
{
"version": "${OP_VERSION}",
"description": "OpenPencil command-line tool",
"homepage": "https://github.com/zseven-w/openpencil",
"license": "MIT",
"architecture": {
"64bit": {
"url": "https://github.com/zseven-w/openpencil/releases/download/v${OP_VERSION}/op-cli-windows-x86_64.zip",
"hash": "${CLI_WIN_X64_SHA}"
},
"arm64": {
"url": "https://github.com/zseven-w/openpencil/releases/download/v${OP_VERSION}/op-cli-windows-aarch64.zip",
"hash": "${CLI_WIN_ARM_SHA}"
}
},
"bin": "op.exe",
"checkver": {
"github": "https://github.com/zseven-w/openpencil"
},
"autoupdate": {
"architecture": {
"64bit": {
"url": "https://github.com/zseven-w/openpencil/releases/download/v\$version/op-cli-windows-x86_64.zip"
},
"arm64": {
"url": "https://github.com/zseven-w/openpencil/releases/download/v\$version/op-cli-windows-aarch64.zip"
}
}
}
}
EOF
jq empty scoop-bucket/bucket/openpencil.json scoop-bucket/bucket/op.json
- name: Commit Scoop updates
shell: bash
run: |
set -euo pipefail
cd scoop-bucket
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
git add bucket/openpencil.json bucket/op.json
if git diff --cached --quiet; then
echo "::notice::Scoop bucket already matches v${OP_VERSION}"
else
git commit -m "Update OpenPencil to v${OP_VERSION}"
git push
fi