Skip to content

Publish

Publish #151

Workflow file for this run

name: Publish
on:
push:
tags:
- 'v*'
# Trusted publishing (npm): id-token: write lets GitHub Actions mint an OIDC
# token that npm CLI auto-exchanges for a short-lived publish token, so we no
# longer rely on a long-lived NPM_TOKEN. See https://docs.npmjs.com/trusted-publishers
permissions:
contents: write
packages: write
id-token: write
jobs:
publish-npm:
runs-on: ubuntu-latest
outputs:
is_prerelease: ${{ steps.meta.outputs.is_prerelease }}
version: ${{ steps.meta.outputs.version }}
steps:
- uses: actions/checkout@v4
- name: Determine release metadata
id: meta
run: |
VERSION="${GITHUB_REF#refs/tags/v}"
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
if [[ "$VERSION" == *"-"* ]]; then
echo "is_prerelease=true" >> "$GITHUB_OUTPUT"
echo "npm_tag=next" >> "$GITHUB_OUTPUT"
else
echo "is_prerelease=false" >> "$GITHUB_OUTPUT"
echo "npm_tag=latest" >> "$GITHUB_OUTPUT"
fi
- uses: pnpm/action-setup@v4
with:
version: 9
- uses: actions/setup-node@v4
with:
node-version: 22
cache: pnpm
registry-url: 'https://registry.npmjs.org'
- run: pnpm install --frozen-lockfile
- name: Build all packages
run: pnpm build
- name: Build Web UI
run: pnpm --filter @markus/web-ui build
- name: Pack Chrome extension
run: node scripts/ensure-chrome-extension-zip.mjs
- name: Bundle CLI for publishing
run: pnpm --filter @markus-global/cli build:bundle
- name: Verify bundle
run: |
ls -lh packages/cli/dist/markus.mjs
ls -d packages/cli/dist/web-ui || { echo "Web UI not bundled"; exit 1; }
ls -d packages/cli/templates || { echo "templates not bundled"; exit 1; }
ls -lh packages/cli/dist/markus-browser-extension.zip
# npm pack dry-run must include the zip
cd packages/cli && npm pack --dry-run 2>&1 | grep -F 'markus-browser-extension.zip'
- name: Upgrade npm CLI (Trusted Publishing needs >= 11.5.1)
run: npm install -g npm@latest
- name: Publish to npm (Trusted Publishing)
run: cd packages/cli && npm publish --access public --provenance --tag ${{ steps.meta.outputs.npm_tag }}
build-server-binary:
needs: publish-npm
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
with:
version: 9
- uses: actions/setup-node@v4
with:
node-version: 22
cache: pnpm
- run: pnpm install --frozen-lockfile
- name: Build all packages
run: pnpm build
- name: Build Web UI
run: pnpm --filter @markus/web-ui build
- name: Pack Chrome extension
run: node scripts/ensure-chrome-extension-zip.mjs
- name: Bundle CLI for publishing
run: pnpm --filter @markus-global/cli build:bundle
- name: Build binary archive
run: bash scripts/build-binary.sh linux x64
- name: Upload versioned installer
uses: actions/upload-artifact@v4
with:
name: markus-linux-x64
path: |
dist-binary/markus-v*-linux-x64.*
!dist-binary/markus-v*-linux-x64/
retention-days: 5
- name: Upload fixed-name installer
uses: actions/upload-artifact@v4
with:
name: markus-setup-linux-x64
path: dist-binary/markus-setup-linux-x64.*
retention-days: 5
build-desktop:
needs: publish-npm
strategy:
fail-fast: false
matrix:
include:
- os: macos-latest
platform: darwin
arch: arm64
electron_args: "--mac --arm64"
- os: macos-latest
platform: darwin
arch: x64
electron_args: "--mac --x64"
- os: ubuntu-latest
platform: linux
arch: x64
electron_args: "--linux --x64"
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
with:
version: 9
- uses: actions/setup-node@v4
with:
node-version: 22
cache: pnpm
- run: pnpm install --frozen-lockfile
- name: Build all packages
run: pnpm build
- name: Build Web UI
run: pnpm --filter @markus/web-ui build
- name: Pack Chrome extension
run: node scripts/ensure-chrome-extension-zip.mjs
- name: Build Electron app
run: pnpm --filter @markus/desktop build:electron
- name: Verify Electron dist includes extension zip
run: ls -lh packages/desktop/dist/markus-browser-extension.zip
- name: Import Apple certificates
if: matrix.platform == 'darwin'
env:
APPLE_CERTIFICATE_P12: ${{ secrets.APPLE_CERTIFICATE_P12 }}
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
run: |
if [ -z "$APPLE_CERTIFICATE_P12" ]; then
echo "⚠ Apple signing secrets not configured — skipping"
exit 0
fi
KEYCHAIN="desktop-$$.keychain-db"
KEYCHAIN_PASSWORD="$(openssl rand -hex 12)"
security create-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN"
security set-keychain-settings -lut 900 "$KEYCHAIN"
security unlock-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN"
echo "$APPLE_CERTIFICATE_P12" | base64 --decode > /tmp/cert.p12
security import /tmp/cert.p12 -k "$KEYCHAIN" -P "$APPLE_CERTIFICATE_PASSWORD" \
-T /usr/bin/codesign
security list-keychains -d user -s "$KEYCHAIN" $(security list-keychains -d user | tr -d '"')
security set-key-partition-list -S apple-tool:,apple: -s -k "$KEYCHAIN_PASSWORD" "$KEYCHAIN"
rm /tmp/cert.p12
echo "DESKTOP_KEYCHAIN=$KEYCHAIN" >> "$GITHUB_ENV"
- name: Package Electron app
working-directory: packages/desktop
run: pnpm dist ${{ matrix.electron_args }}
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
APPLE_ID: ${{ secrets.APPLE_ID }}
APPLE_ID_PASSWORD: ${{ secrets.APPLE_ID_PASSWORD }}
APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_ID_PASSWORD }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
- name: Verify macOS DMG integrity
if: matrix.platform == 'darwin'
run: |
set -euo pipefail
shopt -s nullglob
dmgs=(packages/desktop/dist-electron/*.dmg)
if [ "${#dmgs[@]}" -eq 0 ]; then
echo "::error::No DMG produced under packages/desktop/dist-electron/"
exit 1
fi
if [ "${#dmgs[@]}" -ne 1 ]; then
echo "::error::Expected exactly one DMG for matrix arch ${{ matrix.arch }}, got:"
printf ' %s\n' "${dmgs[@]}"
exit 1
fi
dmg="${dmgs[0]}"
echo "→ hdiutil verify $dmg"
hdiutil verify "$dmg"
# Volume names contain spaces (e.g. "Markus 0.9.6-rc.3-arm64"); do not
# parse hdiutil tabular output with awk $NF. Use a private mountroot.
mountroot="$(mktemp -d /tmp/markus-dmg-XXXXXX)"
cleanup() {
# Detach the volume dir (not the mountroot), then remove the empty root.
# Under set -e, a failed rm on a still-mounted RO volume must not fail the job.
if [ -d "$mountroot" ]; then
while IFS= read -r vol; do
hdiutil detach "$vol" -force >/dev/null 2>&1 || true
done < <(find "$mountroot" -mindepth 1 -maxdepth 1 -type d 2>/dev/null || true)
hdiutil detach "$mountroot" -force >/dev/null 2>&1 || true
rm -rf "$mountroot" 2>/dev/null || true
fi
return 0
}
trap cleanup EXIT
echo "→ hdiutil attach (smoke) $dmg → $mountroot"
hdiutil attach "$dmg" -nobrowse -readonly -mountroot "$mountroot"
app="$(find "$mountroot" -maxdepth 2 -name 'Markus.app' -type d | head -n 1)"
if [ -z "$app" ]; then
echo "::error::Markus.app not found under $mountroot"
find "$mountroot" -maxdepth 3 -print
exit 1
fi
arch_out="$(lipo -archs "$app/Contents/MacOS/Markus")"
echo " app: $app"
echo " arch: $arch_out"
case "${{ matrix.arch }}" in
arm64) echo "$arch_out" | grep -q arm64 ;;
x64) echo "$arch_out" | grep -q x86_64 ;;
esac
cleanup
trap - EXIT
- name: Cleanup Apple keychain
if: always() && matrix.platform == 'darwin' && env.DESKTOP_KEYCHAIN != ''
run: security delete-keychain "$DESKTOP_KEYCHAIN" 2>/dev/null || true
- name: Upload Electron artifacts
uses: actions/upload-artifact@v4
with:
name: markus-desktop-${{ matrix.platform }}-${{ matrix.arch }}
path: |
packages/desktop/dist-electron/*.dmg
packages/desktop/dist-electron/*.exe
packages/desktop/dist-electron/*.AppImage
retention-days: 5
# Windows is built in its own job so the Certum SimplySign signing secrets can
# be gated behind a protected environment (required reviewers) without forcing
# the mac/linux desktop builds through the same manual-approval step.
build-desktop-windows:
needs: publish-npm
runs-on: windows-latest
environment: signing
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
with:
version: 9
- uses: actions/setup-node@v4
with:
node-version: 22
cache: pnpm
- run: pnpm install --frozen-lockfile
- name: Build all packages
run: pnpm build
- name: Build Web UI
run: pnpm --filter @markus/web-ui build
- name: Pack Chrome extension
run: node scripts/ensure-chrome-extension-zip.mjs
- name: Build Electron app
run: pnpm --filter @markus/desktop build:electron
# PowerShell treats `ls -lh` as Get-ChildItem -lh (invalid). Use bash.
- name: Verify Electron dist includes extension zip
shell: bash
run: |
test -f packages/desktop/dist/markus-browser-extension.zip
ls -lh packages/desktop/dist/markus-browser-extension.zip
# Fetch the ssign client used by build/sign.cjs and export its native
# Windows path so electron-builder's custom signer can find it.
#
# Supply-chain: ssign is a small third-party tool that handles our
# long-lived signing credential, so the download is pinned to an exact
# release AND verified against a known SHA-256 before use. If Le-Syl21
# ever re-tags or the CDN is tampered with, the checksum mismatch aborts
# the build instead of running an unknown binary. Bump both values
# together when upgrading (get the digest from the release assets API).
- name: Install ssign (Certum SimplySign client)
shell: bash
env:
SSIGN_VERSION: v0.1.2
SSIGN_SHA256: 973b871de89a55dc25aa6384c437bae5f94a5bb04c5e82c91ec01f3cf50deed9
run: |
set -euo pipefail
curl -fsSL -o ssign.zip \
"https://github.com/Le-Syl21/ssign/releases/download/${SSIGN_VERSION}/ssign-windows-x86_64.zip"
echo "${SSIGN_SHA256} ssign.zip" | sha256sum -c -
powershell -NoProfile -Command "Expand-Archive -Path ssign.zip -DestinationPath ssign_bin -Force"
SSIGN_MSYS="$(find "$PWD/ssign_bin" -name 'ssign.exe' | head -1)"
if [ -z "$SSIGN_MSYS" ]; then echo "::error::ssign.exe not found in release archive"; exit 1; fi
echo "SSIGN_PATH=$(cygpath -w "$SSIGN_MSYS")" >> "$GITHUB_ENV"
# Full signing (app exe + NSIS installer) happens inside electron-builder
# via build/sign.cjs, so .blockmap / latest.yml are generated from the
# already-signed installer. Signing is skipped (non-fatal) if CERTUM_* are
# unset, so the pipeline still produces an unsigned installer for forks.
- name: Package + sign Electron app (Certum SimplySign)
working-directory: packages/desktop
run: pnpm dist --win --x64
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
CERTUM_EMAIL: ${{ secrets.CERTUM_EMAIL }}
CERTUM_OTP: ${{ secrets.CERTUM_OTP }}
- name: Upload Electron artifacts
uses: actions/upload-artifact@v4
with:
name: markus-desktop-win-x64
path: packages/desktop/dist-electron/*.exe
retention-days: 5
github-release:
runs-on: ubuntu-latest
needs: [publish-npm, build-server-binary, build-desktop, build-desktop-windows]
steps:
- uses: actions/checkout@v4
- name: Extract version from tag
id: version
run: echo "VERSION=${GITHUB_REF#refs/tags/v}" >> "$GITHUB_OUTPUT"
- name: Download all artifacts
uses: actions/download-artifact@v4
with:
path: artifacts
merge-multiple: true
- name: List artifacts
run: ls -lhR artifacts/ || echo "No artifacts"
- name: Create GitHub Release (stable)
if: needs.publish-npm.outputs.is_prerelease == 'false'
uses: softprops/action-gh-release@v3
with:
name: v${{ steps.version.outputs.VERSION }}
prerelease: false
generate_release_notes: true
files: artifacts/*
body: |
## Desktop App (recommended)
| Platform | Download |
|----------|----------|
| macOS (Apple Silicon) | [Markus.dmg](https://github.com/markus-global/markus/releases/download/v${{ steps.version.outputs.VERSION }}/Markus-${{ steps.version.outputs.VERSION }}-arm64.dmg) |
| macOS (Intel) | [Markus.dmg](https://github.com/markus-global/markus/releases/download/v${{ steps.version.outputs.VERSION }}/Markus-${{ steps.version.outputs.VERSION }}.dmg) |
| Windows x64 | [Markus-Setup.exe](https://github.com/markus-global/markus/releases/download/v${{ steps.version.outputs.VERSION }}/Markus-Setup-${{ steps.version.outputs.VERSION }}.exe) |
| Linux x64 | [Markus.AppImage](https://github.com/markus-global/markus/releases/download/v${{ steps.version.outputs.VERSION }}/Markus-${{ steps.version.outputs.VERSION }}.AppImage) |
## Server / CLI
```bash
# macOS / Linux (one-liner)
curl -fsSL https://markus.global/install.sh | bash
# npm (requires Node.js 22+)
npm install -g @markus-global/cli@${{ steps.version.outputs.VERSION }}
```
Linux server binary: [markus-setup-linux-x64.deb](https://github.com/markus-global/markus/releases/download/v${{ steps.version.outputs.VERSION }}/markus-setup-linux-x64.deb)
- name: Create GitHub Release (pre-release)
if: needs.publish-npm.outputs.is_prerelease == 'true'
uses: softprops/action-gh-release@v3
with:
name: v${{ steps.version.outputs.VERSION }} (Pre-release)
prerelease: true
generate_release_notes: true
files: artifacts/*
body: |
> **Pre-release** — for testing only. `install.sh` always installs the latest stable.
```bash
npm install -g @markus-global/cli@${{ steps.version.outputs.VERSION }}
```
upload-to-hub:
runs-on: ubuntu-latest
needs: [publish-npm, build-server-binary, build-desktop, build-desktop-windows, github-release]
if: needs.publish-npm.outputs.is_prerelease == 'false'
steps:
- name: Download all artifacts
uses: actions/download-artifact@v4
with:
path: artifacts
merge-multiple: true
- name: Upload to R2
env:
AWS_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }}
R2_ENDPOINT: https://${{ secrets.R2_ACCOUNT_ID }}.r2.cloudflarestorage.com
R2_BUCKET: ${{ secrets.R2_BUCKET_NAME }}
VERSION: ${{ needs.publish-npm.outputs.version }}
run: |
set -e
uploaded=0
for file in artifacts/markus-setup-* artifacts/markus-v${VERSION}-* artifacts/Markus-*.dmg artifacts/Markus-*.AppImage artifacts/Markus*.exe; do
[ -f "$file" ] || continue
filename=$(basename "$file")
echo "Uploading $filename to R2..."
aws s3 cp "$file" "s3://${R2_BUCKET}/releases/${filename}" \
--endpoint-url "$R2_ENDPOINT" \
--content-type "application/octet-stream"
uploaded=$((uploaded + 1))
done
if [ "$uploaded" -eq 0 ]; then
echo "::error::No artifacts found to upload"
exit 1
fi
echo "{\"version\":\"$VERSION\",\"updatedAt\":\"$(date -u +%Y-%m-%dT%H:%M:%SZ)\"}" > /tmp/meta.json
aws s3 cp /tmp/meta.json "s3://${R2_BUCKET}/releases/meta.json" \
--endpoint-url "$R2_ENDPOINT" \
--content-type "application/json"
echo "Uploaded $uploaded files + meta.json to R2"