Skip to content

Commit be7dad9

Browse files
committed
ci(release): add multi-os desktop release workflow and harden CI-flaky contracts
1 parent c35526f commit be7dad9

3 files changed

Lines changed: 220 additions & 0 deletions

File tree

Lines changed: 186 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,186 @@
1+
name: Release Desktop Multi-OS
2+
3+
on:
4+
push:
5+
tags:
6+
- "v*"
7+
workflow_dispatch:
8+
inputs:
9+
tag:
10+
description: "Tag to build and upload (for example: v1.6.1)"
11+
required: true
12+
type: string
13+
14+
permissions:
15+
contents: write
16+
17+
env:
18+
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
19+
20+
jobs:
21+
ensure-release:
22+
name: Ensure GitHub Release Exists
23+
runs-on: ubuntu-latest
24+
outputs:
25+
tag_name: ${{ steps.tag.outputs.tag_name }}
26+
steps:
27+
- name: Resolve tag name
28+
id: tag
29+
shell: bash
30+
run: |
31+
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
32+
TAG_NAME="${{ github.event.inputs.tag }}"
33+
else
34+
TAG_NAME="${GITHUB_REF#refs/tags/}"
35+
fi
36+
if [ -z "$TAG_NAME" ]; then
37+
echo "::error::Unable to resolve release tag."
38+
exit 1
39+
fi
40+
echo "tag_name=$TAG_NAME" >> "$GITHUB_OUTPUT"
41+
echo "Resolved tag: $TAG_NAME"
42+
43+
- name: Ensure release record exists
44+
env:
45+
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
46+
shell: bash
47+
run: |
48+
TAG_NAME="${{ steps.tag.outputs.tag_name }}"
49+
if gh release view "$TAG_NAME" >/dev/null 2>&1; then
50+
echo "Release already exists for $TAG_NAME."
51+
else
52+
gh release create "$TAG_NAME" --title "$TAG_NAME" --notes "Automated desktop multi-OS release assets."
53+
fi
54+
55+
build-and-upload:
56+
name: Build and Upload (${{ matrix.platform_label }})
57+
needs: ensure-release
58+
runs-on: ${{ matrix.runner }}
59+
strategy:
60+
fail-fast: false
61+
matrix:
62+
include:
63+
- runner: windows-latest
64+
platform_label: windows
65+
files: |
66+
src-tauri/target/release/bundle/**/*.exe
67+
src-tauri/target/release/bundle/**/*.msi
68+
- runner: ubuntu-latest
69+
platform_label: linux
70+
files: |
71+
src-tauri/target/release/bundle/**/*.AppImage
72+
src-tauri/target/release/bundle/**/*.deb
73+
- runner: macos-13
74+
platform_label: macos
75+
files: |
76+
src-tauri/target/release/bundle/**/*.dmg
77+
78+
steps:
79+
- name: Checkout repository
80+
uses: actions/checkout@v5
81+
with:
82+
ref: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.tag || github.ref }}
83+
fetch-depth: 0
84+
lfs: true
85+
86+
- name: Setup Node.js
87+
uses: actions/setup-node@v5
88+
with:
89+
node-version: "20"
90+
cache: "npm"
91+
92+
- name: Setup Rust toolchain
93+
uses: dtolnay/rust-toolchain@stable
94+
95+
- name: Install Linux native dependencies
96+
if: runner.os == 'Linux'
97+
shell: bash
98+
run: |
99+
sudo apt-get update
100+
if sudo apt-get install -y \
101+
libwebkit2gtk-4.1-dev \
102+
libgtk-3-dev \
103+
librsvg2-dev \
104+
patchelf \
105+
libappindicator3-dev \
106+
libayatana-appindicator3-dev; then
107+
echo "Installed WebKitGTK 4.1 dependency stack."
108+
else
109+
sudo apt-get install -y \
110+
libwebkit2gtk-4.0-dev \
111+
libgtk-3-dev \
112+
librsvg2-dev \
113+
patchelf \
114+
libappindicator3-dev \
115+
libayatana-appindicator3-dev
116+
echo "Installed WebKitGTK 4.0 fallback dependency stack."
117+
fi
118+
119+
- name: Install dependencies
120+
run: npm ci
121+
122+
- name: Prepare Godot sidecar binary (Windows)
123+
if: runner.os == 'Windows'
124+
shell: pwsh
125+
run: |
126+
$ErrorActionPreference = "Stop"
127+
New-Item -ItemType Directory -Path "build\godot" -Force | Out-Null
128+
$archive = "build\godot\godot-win64.zip"
129+
Invoke-WebRequest -Uri "https://github.com/godotengine/godot/releases/download/4.3-stable/Godot_v4.3-stable_win64.exe.zip" -OutFile $archive
130+
Expand-Archive -Path $archive -DestinationPath "build\godot\extract" -Force
131+
$godotExe = Get-ChildItem -Path "build\godot\extract" -Filter "*.exe" -Recurse | Select-Object -First 1
132+
if (-not $godotExe) {
133+
throw "Failed to locate extracted Godot Windows executable."
134+
}
135+
Copy-Item -Path $godotExe.FullName -Destination "src-tauri\bin\godot-x86_64-pc-windows-msvc.exe" -Force
136+
137+
- name: Prepare Godot sidecar binary (Linux)
138+
if: runner.os == 'Linux'
139+
shell: bash
140+
run: |
141+
set -euo pipefail
142+
mkdir -p build/godot
143+
curl -fsSL "https://github.com/godotengine/godot/releases/download/4.3-stable/Godot_v4.3-stable_linux.x86_64.zip" -o build/godot/godot-linux.zip
144+
unzip -q -o build/godot/godot-linux.zip -d build/godot/extract
145+
GODOT_BIN="$(find build/godot/extract -maxdepth 2 -type f -name 'Godot_v4.3-stable_linux.x86_64' | head -n 1)"
146+
if [ -z "${GODOT_BIN}" ]; then
147+
echo "Failed to locate extracted Godot Linux executable."
148+
exit 1
149+
fi
150+
cp "${GODOT_BIN}" src-tauri/bin/godot-x86_64-unknown-linux-gnu
151+
chmod +x src-tauri/bin/godot-x86_64-unknown-linux-gnu
152+
153+
- name: Prepare Godot sidecar binary (macOS)
154+
if: runner.os == 'macOS'
155+
shell: bash
156+
run: |
157+
set -euo pipefail
158+
mkdir -p build/godot
159+
curl -fsSL "https://github.com/godotengine/godot/releases/download/4.3-stable/Godot_v4.3-stable_macos.universal.zip" -o build/godot/godot-macos.zip
160+
unzip -q -o build/godot/godot-macos.zip -d build/godot/extract
161+
GODOT_BIN="$(find build/godot/extract -type f -path '*Godot.app/Contents/MacOS/Godot' | head -n 1)"
162+
if [ -z "${GODOT_BIN}" ]; then
163+
echo "Failed to locate extracted Godot macOS executable."
164+
exit 1
165+
fi
166+
cp "${GODOT_BIN}" src-tauri/bin/godot-x86_64-apple-darwin
167+
chmod +x src-tauri/bin/godot-x86_64-apple-darwin
168+
169+
- name: Build desktop bundle
170+
run: npm run tauri:build:mini
171+
172+
- name: Upload workflow artifacts
173+
uses: actions/upload-artifact@v4
174+
with:
175+
name: release-${{ needs.ensure-release.outputs.tag_name }}-${{ matrix.platform_label }}
176+
if-no-files-found: error
177+
path: ${{ matrix.files }}
178+
179+
- name: Upload assets to GitHub release
180+
uses: softprops/action-gh-release@v2
181+
with:
182+
tag_name: ${{ needs.ensure-release.outputs.tag_name }}
183+
files: ${{ matrix.files }}
184+
fail_on_unmatched_files: true
185+
env:
186+
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

src/sbom.attestation.policy.contract.test.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,34 @@ describe('sbom attestation policy contract', () => {
1919
const verifierPath = path.join(repoRoot, 'scripts', 'verify-sbom-attestation.js');
2020
const migrationWorkflowPath = path.join(repoRoot, '.github', 'workflows', 'migration-gates.yml');
2121
const npmPublishWorkflowPath = path.join(repoRoot, '.github', 'workflows', 'npm-publish.yml');
22+
let noteConnectionEnvSnapshot: Record<string, string | undefined>;
23+
24+
beforeEach(() => {
25+
// Keep contract tests hermetic even when CI injects NOTE_CONNECTION_* policy env vars.
26+
noteConnectionEnvSnapshot = {};
27+
for (const key of Object.keys(process.env)) {
28+
if (!key.startsWith('NOTE_CONNECTION_')) {
29+
continue;
30+
}
31+
noteConnectionEnvSnapshot[key] = process.env[key];
32+
delete process.env[key];
33+
}
34+
});
35+
36+
afterEach(() => {
37+
for (const key of Object.keys(process.env)) {
38+
if (key.startsWith('NOTE_CONNECTION_')) {
39+
delete process.env[key];
40+
}
41+
}
42+
for (const [key, value] of Object.entries(noteConnectionEnvSnapshot || {})) {
43+
if (typeof value === 'undefined') {
44+
delete process.env[key];
45+
} else {
46+
process.env[key] = value;
47+
}
48+
}
49+
});
2250

2351
test('exports attestation generation/verification scripts and gate wiring', () => {
2452
const packageJson = readJson<PackageJson>(packageJsonPath);

src/server.migration.test.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -256,6 +256,7 @@ describe('server migration settings routes', () => {
256256
let buildGraphMock: jest.Mock;
257257
let renderMathPngMock: jest.Mock;
258258
let renderMermaidPngMock: jest.Mock;
259+
let copyPngToClipboardMock: jest.Mock;
259260
let originalArgv: string[];
260261

261262
beforeAll(async () => {
@@ -301,6 +302,7 @@ describe('server migration settings routes', () => {
301302
width: 640,
302303
height: 360
303304
});
305+
copyPngToClipboardMock = jest.fn().mockResolvedValue(undefined);
304306
jest.resetModules();
305307
originalArgv = [...process.argv];
306308
process.argv = process.argv.slice(0, 2);
@@ -314,6 +316,9 @@ describe('server migration settings routes', () => {
314316
renderMathPng: renderMathPngMock,
315317
renderMermaidPng: renderMermaidPngMock
316318
}));
319+
jest.doMock('./native_clipboard', () => ({
320+
copyPngToClipboard: copyPngToClipboardMock
321+
}));
317322

318323
const serverModule = require('./server') as {
319324
startServer: (options?: { port?: number; targetPath?: string }) => Promise<Server>;
@@ -342,6 +347,7 @@ describe('server migration settings routes', () => {
342347
jest.dontMock('./index');
343348
jest.dontMock('./core/PathBridge');
344349
jest.dontMock('./reader_renderer');
350+
jest.dontMock('./native_clipboard');
345351
process.argv = originalArgv;
346352
temp.cleanup();
347353
});

0 commit comments

Comments
 (0)