Skip to content

publish

publish #649

Workflow file for this run

name: "publish"
# change this when ready to release if you want CI/CD
on:
workflow_dispatch:
inputs:
interactionId:
description: "Discord Interaction ID"
required: false
type: string
env:
CN_APPLICATION: cap/cap
APP_CARGO_TOML: apps/desktop/src-tauri/Cargo.toml
SENTRY_ORG: cap-s2
SENTRY_PROJECT: cap-desktop
jobs:
draft:
runs-on: ubuntu-latest
outputs:
version: ${{ steps.read_version.outputs.value }}
tag_name: ${{ steps.create_tag.outputs.tag_name }}
needs_release: ${{ steps.create_tag.outputs.release_published != 'true' }}
cn_release_stdout: ${{ steps.create_cn_release.outputs.stdout }}
gh_release_url: ${{ steps.create_gh_release.outputs.url || steps.create_tag.outputs.release_url }}
permissions:
contents: write
id-token: write
steps:
- uses: actions/checkout@v4
- name: Read version number
uses: SebRollen/toml-action@v1.0.2
id: read_version
with:
file: ${{ env.APP_CARGO_TOML }}
field: "package.version"
- name: Prepare release
id: create_tag
uses: actions/github-script@v7
with:
script: |
const tag = "cap-v${{ steps.read_version.outputs.value }}";
const tagRef = `tags/${tag}`;
const TAG_EXISTED = "tag_existed";
const TAG_NAME = "tag_name";
const RELEASE_PUBLISHED = "release_published";
const RELEASE_URL = "release_url";
core.setOutput(TAG_NAME, tag);
async function main() {
let tagExisted = true;
let tagSha = "";
let releasePublished = false;
let releaseUrl = "";
try {
const tagRefResponse = await github.rest.git.getRef({
ref: tagRef,
owner: context.repo.owner,
repo: context.repo.repo,
});
tagExisted = true;
tagSha = tagRefResponse.data.object.sha;
core.notice(`Tag '${tag}' already exists. Reusing it for this release run.`);
} catch (error) {
if ("status" in error && error.status === 404) tagExisted = false;
else throw error;
}
if (!tagExisted)
await github.rest.git.createRef({
ref: `refs/${tagRef}`,
owner: context.repo.owner,
repo: context.repo.repo,
sha: context.sha,
});
const releases = await github.paginate(github.rest.repos.listReleases, {
owner: context.repo.owner,
repo: context.repo.repo,
per_page: 100,
});
const release = releases.find((release) => release.tag_name === tag);
if (release) {
releaseUrl = release.html_url;
releasePublished = !release.draft;
if (releasePublished)
throw new Error(`Release '${tag}' is already published: ${releaseUrl}. Bump apps/desktop/src-tauri/Cargo.toml before publishing a new build.`);
else
core.notice(`Draft release '${tag}' already exists. Continuing build and upload.`);
}
if (tagExisted && !releasePublished && tagSha !== context.sha) {
await github.rest.git.updateRef({
ref: tagRef,
owner: context.repo.owner,
repo: context.repo.repo,
sha: context.sha,
force: true,
});
core.notice(`Updated unpublished tag '${tag}' to ${context.sha}.`);
}
core.setOutput(TAG_EXISTED, tagExisted);
core.setOutput(RELEASE_PUBLISHED, releasePublished);
core.setOutput(RELEASE_URL, releaseUrl);
}
await main();
- name: Create draft CN release
id: create_cn_release
if: ${{ steps.create_tag.outputs.release_published != 'true' }}
uses: crabnebula-dev/cloud-release@v0
with:
command: release draft ${{ env.CN_APPLICATION }} ${{ steps.read_version.outputs.value }} --framework tauri
api-key: ${{ secrets.CN_API_KEY }}
- name: Create draft GH release
id: create_gh_release
if: ${{ steps.create_tag.outputs.release_published != 'true' }}
# TODO: Change to stable version when available
uses: softprops/action-gh-release@v2
with:
name: ${{ steps.read_version.outputs.value }}
tag_name: ${{ steps.create_tag.outputs.tag_name }}
draft: true
generate_release_notes: true
- name: Update Discord interaction
if: ${{ inputs.interactionId != '' && steps.create_tag.outputs.release_published != 'true' }}
uses: actions/github-script@v7
with:
script: |
async function main() {
const token = await core.getIDToken("cap-discord-bot");
const cnReleaseId = JSON.parse(`${{ steps.create_cn_release.outputs.stdout }}`).id;
const resp = await fetch("https://cap-discord-bot.brendonovich.workers.dev/github-workflow", {
method: "POST",
body: JSON.stringify({
type: "release-ready",
tag: "${{ steps.create_tag.outputs.tag_name }}",
version: "${{ steps.read_version.outputs.value }}",
releaseUrl: "${{ steps.create_gh_release.outputs.url }}",
interactionId: "${{ inputs.interactionId }}",
cnReleaseId
}),
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
}
});
if(resp.status !== 200) throw new Error(await resp.text());
}
main();
build:
needs: draft
if: ${{ needs.draft.outputs.needs_release == 'true' }}
permissions:
contents: write
actions: read
id-token: write
strategy:
fail-fast: false
matrix:
settings:
- target: x86_64-apple-darwin
runner: macos-latest-xlarge
- target: aarch64-apple-darwin
runner: macos-latest-xlarge
- target: x86_64-pc-windows-msvc
runner: windows-2022
- target: x86_64-unknown-linux-gnu
runner: ubuntu-24.04
env:
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
TURBO_TEAM: ${{ secrets.TURBO_TEAM }}
runs-on: ${{ matrix.settings.runner }}
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
ref: ${{ needs.draft.outputs.tag_name }}
- name: Install Linux desktop dependencies
if: ${{ runner.os == 'Linux' }}
uses: ./.github/actions/install-desktop-deps
- name: Create API Key File
if: ${{ runner.os == 'macOS' }}
run: echo "${{ secrets.APPLE_API_KEY_FILE }}" > api.p8
- uses: apple-actions/import-codesign-certs@8f3fb608891dd2244cdab3d69cd68c0d37a7fe93 # v2
if: ${{ runner.os == 'macOS' }}
with:
p12-file-base64: ${{ secrets.APPLE_CERTIFICATE }}
p12-password: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
- name: Verify certificate
if: ${{ runner.os == 'macOS' }}
run: security find-identity -v -p codesigning ${{ runner.temp }}/build.keychain
- name: Rust setup
uses: dtolnay/rust-toolchain@1.88.0
with:
targets: ${{ matrix.settings.target }}
- name: Verify Rust toolchain
shell: bash
run: |
rustc -Vv
cargo -V
rustup show active-toolchain
- uses: ./.github/actions/setup-rust-cache
with:
target: ${{ matrix.settings.target }}
- uses: ./.github/actions/setup-js
- name: Create .env file in root
shell: bash
run: |
{
echo "appVersion=${{ needs.draft.outputs.version }}"
echo "VITE_ENVIRONMENT=production"
echo "CAP_DESKTOP_SENTRY_URL=https://6a3b6a09e6ae976c2ad6fff710e88748@o4506859771527168.ingest.us.sentry.io/4508330917101568"
echo "NEXT_PUBLIC_WEB_URL=${{ secrets.NEXT_PUBLIC_WEB_URL }}"
echo 'NEXTAUTH_URL=${{ secrets.NEXT_PUBLIC_WEB_URL }}'
echo 'VITE_POSTHOG_KEY=${{ secrets.VITE_POSTHOG_KEY }}'
echo 'VITE_POSTHOG_HOST=${{ secrets.VITE_POSTHOG_HOST }}'
echo 'VITE_SERVER_URL=${{ secrets.NEXT_PUBLIC_WEB_URL }}'
echo 'RUST_TARGET_TRIPLE=${{ matrix.settings.target }}'
} >> .env
- name: Run cap-setup
shell: bash
run: pnpm -w cap-setup
env:
RUST_TARGET_TRIPLE: ${{ matrix.settings.target }}
APPLE_SIGNING_IDENTITY: ${{ runner.os == 'macOS' && secrets.APPLE_SIGNING_IDENTITY || '' }}
APPLE_KEYCHAIN: ${{ runner.os == 'macOS' && format('{0}/build.keychain', runner.temp) || '' }}
- name: Build desktop binaries
shell: bash
run: |
if [[ "$RUNNER_OS" == "Linux" ]]; then
export CARGO_PROFILE_RELEASE_DEBUG=0
fi
./scripts/build-desktop-binaries.sh ${{ matrix.settings.target }}
# The release profile uses fat LTO + full debuginfo, so the final link
# peaks well above the 16GB stock Linux runner and gets OOM-killed
# ("runner received a shutdown signal"). The runner already ships an
# active /swapfile (resizing it fails with "Text file busy"), so add a
# large swapfile on the roomy /mnt disk on top of the existing swap.
# Linux-only; macOS/Windows are unaffected.
- name: Increase swap space (Linux)
if: ${{ runner.os == 'Linux' }}
shell: bash
run: |
sudo swapoff /mnt/swapfile 2>/dev/null || true
sudo rm -f /mnt/swapfile
sudo fallocate -l 16G /mnt/swapfile
sudo chmod 600 /mnt/swapfile
sudo mkswap /mnt/swapfile
sudo swapon /mnt/swapfile
swapon --show
free -h
- name: Build app
working-directory: apps/desktop
run: ${{ runner.os == 'Linux' && 'CARGO_PROFILE_RELEASE_DEBUG=0 ' || '' }}pnpm build:tauri --target ${{ matrix.settings.target }} --config src-tauri/tauri.prod.conf.json ${{ runner.os == 'Windows' && '--bundles nsis' || runner.os == 'Linux' && '--bundles deb --verbose' || '' }}
env:
# https://github.com/tauri-apps/tauri-action/issues/740
CI: false
LD_LIBRARY_PATH: ${{ runner.os == 'Linux' && format('{0}/target/{1}/release:{0}/target/native-deps/lib', github.workspace, matrix.settings.target) || '' }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# codesigning
APPLE_CERTIFICATE: ${{ runner.os == 'macOS' && secrets.APPLE_CERTIFICATE || '' }}
APPLE_CERTIFICATE_PASSWORD: ${{ runner.os == 'macOS' && secrets.APPLE_CERTIFICATE_PASSWORD || '' }}
APPLE_SIGNING_IDENTITY: ${{ runner.os == 'macOS' && secrets.APPLE_SIGNING_IDENTITY || '' }}
# notarization
APPLE_API_ISSUER: ${{ runner.os == 'macOS' && secrets.APPLE_API_ISSUER || '' }}
APPLE_API_KEY: ${{ runner.os == 'macOS' && secrets.APPLE_API_KEY || '' }}
APPLE_API_KEY_PATH: ${{ runner.os == 'macOS' && format('{0}/api.p8', github.workspace) || '' }}
APPLE_KEYCHAIN: ${{ runner.os == 'macOS' && format('{0}/build.keychain', runner.temp) || '' }}
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
RUST_TARGET_TRIPLE: ${{ matrix.settings.target }}
# Guards against the Linux packaging regressions:
# 1. Frontend missing: vinxi must produce apps/desktop/.output/public,
# which Tauri EMBEDS into the binary (it is NOT shipped as a loose
# /usr/lib/Cap/index.html file). A blank webview ships if turbo's cache
# restores an empty .output/public (see @cap/desktop#build outputs in
# turbo.json), so we assert the build output exists and is non-empty.
# 2. FFmpeg soname mismatch: every libav*/libsw* the main binary lists as
# NEEDED must be bundled under /usr/lib/cap/ with the exact same soname.
# The bundled libs come from native-deps (FFmpeg 7); if the binary
# linked system FFmpeg 6 the loader fails at launch.
# 3. Undeclared runtime dependency: a system lib the binary NEEDs that is
# neither bundled nor part of the declared GTK/webkit stack must appear
# in the .deb Depends, or the loader aborts at launch. This is the
# libpipewire-0.3.so.0 crash that shipped in 0.5.2.
- name: Verify Linux deb contents
if: ${{ runner.os == 'Linux' }}
shell: bash
run: |
set -euo pipefail
# (1) frontend was actually built. Tauri embeds .output/public into the
# binary at build time, so an empty/missing dir => blank webview.
FE="apps/desktop/.output/public/index.html"
if [[ ! -s "$FE" ]]; then
echo "::error::Frontend build output missing/empty ($FE) - webview would be blank."
ls -la apps/desktop/.output/public 2>/dev/null || echo "(no .output/public dir)"
exit 1
fi
echo "Frontend build output OK ($FE, $(wc -c < "$FE") bytes)"
UNSUPPORTED_ARTIFACT="$(find target/${{ matrix.settings.target }}/release/bundle -type f \( -name '*.AppImage' -o -name '*.rpm' \) -print -quit)"
if [[ -n "$UNSUPPORTED_ARTIFACT" ]]; then
echo "::error::Unexpected Linux non-deb artifact produced: $UNSUPPORTED_ARTIFACT"
exit 1
fi
DEB="$(find target/${{ matrix.settings.target }}/release/bundle/deb -name '*.deb' | head -n1)"
if [[ -z "$DEB" ]]; then echo "::error::No .deb produced"; exit 1; fi
if [[ ! -s "$DEB.sig" ]]; then echo "::error::No .deb updater signature produced next to $DEB"; exit 1; fi
echo "Inspecting $DEB"
WORK="$(mktemp -d)"
dpkg-deb -x "$DEB" "$WORK"
# Best-effort confirmation that the SPA actually got embedded into the
# binary (Tauri stores asset path keys as plaintext). Informational only.
if strings "$WORK/usr/bin/Cap" 2>/dev/null | grep -q "index.html"; then
echo "Binary appears to embed the frontend (index.html key present)"
else
echo "::warning::Could not confirm embedded frontend key in binary (asset format may differ)"
fi
# (2) every FFmpeg soname the binary NEEDs is bundled
BIN="$WORK/usr/bin/Cap"
NEEDED="$(objdump -p "$BIN" | awk '/NEEDED/{print $2}' | grep -E '^lib(av|sw|postproc)' || true)"
echo "Binary NEEDs FFmpeg sonames:"; echo "${NEEDED:-(none)}"
echo "Bundled FFmpeg libs:"; ls -1 "$WORK/usr/lib/cap" 2>/dev/null || true
MISSING=0
for so in $NEEDED; do
if [[ ! -f "$WORK/usr/lib/cap/$so" ]]; then
echo "::error::Binary NEEDs $so but it is not bundled under /usr/lib/cap/"
MISSING=1
fi
done
if [[ "$MISSING" -ne 0 ]]; then
echo "::error::FFmpeg soname mismatch between binary and bundled libs."
exit 1
fi
echo "FFmpeg soname bundle OK"
# (3) runtime libs that are NOT bundled and NOT pulled in by the
# declared GTK/webkit stack must be declared in the .deb Depends, or
# the dynamic loader aborts at launch (the libpipewire-0.3.so.0 crash
# in 0.5.2). Assert the curated set of such sonames the binary links.
DEPENDS="$(dpkg-deb -f "$DEB" Depends || true)"
echo "Declared Depends: ${DEPENDS:-(none)}"
NEEDED_ALL="$(objdump -p "$BIN" | awk '/NEEDED/{print $2}')"
# soname -> a Depends token that satisfies it. The alsa token is a
# substring so it matches both libasound2 and libasound2t64.
declare -A SONAME_DEP=(
[libpipewire-0.3.so.0]="libpipewire-0.3-0"
[libasound.so.2]="libasound2"
)
DEP_MISSING=0
for so in "${!SONAME_DEP[@]}"; do
if echo "$NEEDED_ALL" | grep -qx "$so"; then
tok="${SONAME_DEP[$so]}"
if ! echo "$DEPENDS" | grep -q "$tok"; then
echo "::error::Binary NEEDs $so but '$tok' is not declared in .deb Depends (add it to tauri.conf.json bundle.linux.deb.depends)."
DEP_MISSING=1
else
echo "Runtime dep OK: $so -> $tok"
fi
fi
done
if [[ "$DEP_MISSING" -ne 0 ]]; then
echo "::error::Undeclared runtime dependency in .deb Depends."
exit 1
fi
echo "Runtime dependency declarations OK"
- name: Upload unsigned Windows installer
if: ${{ runner.os == 'Windows' }}
id: upload_unsigned_windows_installer
uses: actions/upload-artifact@v4
with:
name: unsigned-windows-installer
path: target/${{ matrix.settings.target }}/release/bundle/nsis/*.exe
if-no-files-found: error
- name: Submit SignPath signing request
if: ${{ runner.os == 'Windows' }}
id: submit_signpath_signing_request
uses: signpath/github-action-submit-signing-request@v1
with:
api-token: ${{ secrets.SIGNPATH_API_TOKEN }}
organization-id: ${{ secrets.SIGNPATH_ORGANIZATION_ID }}
project-slug: ${{ secrets.SIGNPATH_PROJECT_SLUG }}
signing-policy-slug: ${{ secrets.SIGNPATH_SIGNING_POLICY_SLUG }}
artifact-configuration-slug: ${{ secrets.SIGNPATH_ARTIFACT_CONFIGURATION_SLUG }}
github-artifact-id: ${{ steps.upload_unsigned_windows_installer.outputs.artifact-id }}
wait-for-completion: true
output-artifact-directory: signed-windows-installer
- name: Restore signed Windows installer
if: ${{ runner.os == 'Windows' }}
shell: pwsh
run: |
$signedDir = "signed-windows-installer"
$bundleDir = "target/${{ matrix.settings.target }}/release/bundle/nsis"
if (-not (Test-Path $signedDir)) {
throw "Signed artifact directory '$signedDir' not found."
}
$executables = Get-ChildItem -Path $signedDir -Filter *.exe -Recurse
if (-not $executables) {
throw "No signed executables found in '$signedDir'."
}
Write-Host "Copying signed executables to: $bundleDir"
Copy-Item -Path (Join-Path $signedDir '*.exe') -Destination $bundleDir -Force
Write-Host "Files in bundle directory after signing:"
Get-ChildItem -Path $bundleDir -Filter *.exe | ForEach-Object { Write-Host " - $($_.Name)" }
- name: Re-sign Windows installer for Tauri updater
if: ${{ runner.os == 'Windows' }}
shell: bash
working-directory: apps/desktop
run: |
BUNDLE_DIR="../../target/${{ matrix.settings.target }}/release/bundle/nsis"
for exe in "$BUNDLE_DIR"/*.exe; do
echo "Re-signing $(basename "$exe") for Tauri updater..."
rm -f "${exe}.sig"
pnpm tauri signer sign -k "$TAURI_SIGNING_PRIVATE_KEY" -p "$TAURI_SIGNING_PRIVATE_KEY_PASSWORD" "$exe"
done
echo "Signature files after re-signing:"
ls -la "$BUNDLE_DIR"/*.sig
env:
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
- name: Upload assets
if: ${{ runner.os != 'Linux' }}
uses: crabnebula-dev/cloud-release@v0
with:
working-directory: apps/desktop
command: release upload ${{ env.CN_APPLICATION }} "${{ needs.draft.outputs.version }}" --framework tauri
api-key: ${{ secrets.CN_API_KEY }}
env:
TAURI_BUNDLE_PATH: ../..
- name: Upload Linux deb asset
if: ${{ runner.os == 'Linux' }}
uses: crabnebula-dev/cloud-release@v0
with:
working-directory: apps/desktop
command: release upload ${{ env.CN_APPLICATION }} "${{ needs.draft.outputs.version }}" --file "../../target/${{ matrix.settings.target }}/release/bundle/deb/Cap_${{ needs.draft.outputs.version }}_amd64.deb" --signature "../../target/${{ matrix.settings.target }}/release/bundle/deb/Cap_${{ needs.draft.outputs.version }}_amd64.deb.sig" --public-platform deb-x86_64 --update-platform linux-x86_64-deb
api-key: ${{ secrets.CN_API_KEY }}
- uses: matbour/setup-sentry-cli@8ef22a4ff03bcd1ebbcaa3a36a81482ca8e3872e
- name: Upload debug symbols to Sentry
if: ${{ runner.os == 'macOS' }}
shell: bash
env:
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
run: |
sentry-cli debug-files upload -o ${{ env.SENTRY_ORG }} -p ${{ env.SENTRY_PROJECT }} target/Cap.dSYM
- name: Upload debug symbols to Sentry
if: ${{ runner.os == 'Windows' }}
shell: bash
env:
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
run: |
sentry-cli debug-files upload -o ${{ env.SENTRY_ORG }} -p ${{ env.SENTRY_PROJECT }} target/${{ matrix.settings.target }}/release/cap_desktop.pdb
done:
needs: [draft, build]
runs-on: ubuntu-latest
permissions:
contents: write
id-token: write
steps:
- name: Send Discord notification
if: ${{ inputs.interactionId != '' }}
uses: actions/github-script@v7
with:
script: |
async function main() {
const token = await core.getIDToken("cap-discord-bot");
const cnReleaseId = JSON.parse(`${{ needs.draft.outputs.cn_release_stdout }}`).id;
const resp = await fetch("https://cap-discord-bot.brendonovich.workers.dev/github-workflow", {
method: "POST",
body: JSON.stringify({
type: "release-done",
interactionId: "${{ inputs.interactionId }}",
version: "${{ needs.draft.outputs.version }}",
releaseUrl: "${{ needs.draft.outputs.gh_release_url }}",
cnReleaseId
}),
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
}
});
if(resp.status !== 200) throw new Error(await resp.text());
}
main();