-
Notifications
You must be signed in to change notification settings - Fork 551
Add a one-button release pipeline (all platforms, one commit, verified draft) #282
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,352 @@ | ||
| # One-button releases: builds macOS (arm64 + x64, signed, notarized, stapled), | ||
| # Windows, and Ubuntu from ONE commit, assembles a complete draft on | ||
| # openmausbot-releases with verified feeds, and publishes after a human | ||
| # approves the draft. | ||
| # | ||
| # Every gate below exists because its absence shipped (or nearly shipped) a | ||
| # broken build from a laptop: | ||
| # - clean output dirs -> 0.1.15 "app is damaged" (stale dist/ broke the seal) | ||
| # - codesign gate BEFORE -> notarization ACCEPTS invalid signatures | ||
| # - packaged-server smoke -> 0.1.24 died on launch (unbundled zod import) | ||
| # - proxy-path probe -> the 0.1.24 fix broke every helper while | ||
| # /api/health stayed green | ||
| # - staple, THEN hash -> stapling rewrites bytes; stale feeds make | ||
| # electron-updater reject every update | ||
| # - blockmap regeneration -> stale differential maps waste every updater's | ||
| # bandwidth silently | ||
| # - assemble-then-publish -> 0.1.24 sat as an invisible draft; a mac-only | ||
| # publish 404s the README's other buttons | ||
| # | ||
| # One-time setup (repo secrets) is documented in docs/releasing.md. | ||
| name: Release | ||
|
|
||
| on: | ||
| workflow_dispatch: | ||
| inputs: | ||
| ref: | ||
| description: "Commit/tag/branch to release (defaults to the branch this runs from)" | ||
| required: false | ||
| type: string | ||
| publish: | ||
| description: "Publish immediately after assembly (otherwise leave a draft for review)" | ||
| required: false | ||
| type: boolean | ||
| default: false | ||
|
|
||
| permissions: | ||
| contents: read | ||
|
|
||
| jobs: | ||
| prepare: | ||
| name: Pin the release commit | ||
| runs-on: ubuntu-24.04 | ||
| timeout-minutes: 5 | ||
| outputs: | ||
| sha: ${{ steps.pin.outputs.sha }} | ||
| version: ${{ steps.pin.outputs.version }} | ||
| steps: | ||
| - uses: actions/checkout@v4 | ||
| with: | ||
| ref: ${{ inputs.ref || github.ref }} | ||
| persist-credentials: false | ||
| - id: pin | ||
| run: | | ||
| echo "sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT" | ||
| echo "version=$(node -p "require('./package.json').version")" >> "$GITHUB_OUTPUT" | ||
| - name: Refuse to overwrite a published release | ||
| env: | ||
| GH_TOKEN: ${{ secrets.RELEASES_PAT }} | ||
| run: | | ||
| v="v$(node -p "require('./package.json').version")" | ||
| if gh release view "$v" --repo milind-soni/openmausbot-releases --json isDraft --jq .isDraft 2>/dev/null | grep -q false; then | ||
| echo "::error::$v is already published on openmausbot-releases — bump the version first" | ||
| exit 1 | ||
| fi | ||
|
|
||
| mac: | ||
| name: macOS arm64 + x64 (sign, notarize, staple) | ||
| needs: prepare | ||
| runs-on: macos-14 | ||
| timeout-minutes: 90 | ||
| steps: | ||
| - uses: actions/checkout@v4 | ||
| with: | ||
| ref: ${{ needs.prepare.outputs.sha }} | ||
| persist-credentials: false | ||
| - uses: pnpm/action-setup@v4 | ||
| - uses: actions/setup-node@v4 | ||
| with: | ||
| node-version: 24 | ||
| cache: pnpm | ||
| - run: pnpm install --frozen-lockfile | ||
| - name: Clean generated output | ||
| run: rm -rf dist dist-server dist-native release | ||
|
|
||
| - name: Import the Developer ID certificate into a throwaway keychain | ||
| env: | ||
| MAC_CERT_P12_BASE64: ${{ secrets.MAC_CERT_P12_BASE64 }} | ||
| MAC_CERT_PASSWORD: ${{ secrets.MAC_CERT_PASSWORD }} | ||
| run: | | ||
| KEYCHAIN="$RUNNER_TEMP/release.keychain-db" | ||
| KEYCHAIN_PASSWORD="$(uuidgen)" | ||
| echo "$MAC_CERT_P12_BASE64" | base64 --decode > "$RUNNER_TEMP/cert.p12" | ||
| security create-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN" | ||
| security set-keychain-settings -lut 21600 "$KEYCHAIN" | ||
| security unlock-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN" | ||
| security import "$RUNNER_TEMP/cert.p12" -P "$MAC_CERT_PASSWORD" -A \ | ||
| -t cert -f pkcs12 -k "$KEYCHAIN" | ||
| security set-key-partition-list -S apple-tool:,apple: -k "$KEYCHAIN_PASSWORD" "$KEYCHAIN" | ||
| security list-keychains -d user -s "$KEYCHAIN" login.keychain | ||
| rm "$RUNNER_TEMP/cert.p12" | ||
|
|
||
| - name: Package both architectures | ||
| run: pnpm package:mac | ||
|
|
||
| - name: "Gate: the signature must verify BEFORE notarization" | ||
| run: | | ||
| for app in release/mac-arm64/OpenMausBot.app release/mac/OpenMausBot.app; do | ||
| codesign --verify --deep --strict "$app" | ||
| echo "ok: $app" | ||
| done | ||
|
|
||
| - name: "Gate: both packaged servers start and every helper path resolves" | ||
| run: | | ||
| for app in release/mac-arm64/OpenMausBot.app release/mac/OpenMausBot.app; do | ||
| res="$app/Contents/Resources" | ||
| # the server tree is plain JS — runnable on the runner's node | ||
| # regardless of the app binary's arch | ||
| OMB_SMOKE_DIST="$res/server" node scripts/smoke-packaged-server.mjs \ | ||
| || { echo "::error::packaged server in $app failed to start"; exit 1; } | ||
| node --input-type=module - "$res/server" <<'EOF' | ||
| import { existsSync } from "node:fs"; | ||
| import { pathToFileURL } from "node:url"; | ||
| const server = process.argv[2]; | ||
| const { SPAWNED_PROXIES } = await import(pathToFileURL(`${server}/proxy-paths.js`)); | ||
| const missing = Object.entries(SPAWNED_PROXIES).filter(([, p]) => !existsSync(p)); | ||
| if (missing.length) { | ||
| console.error("unresolved proxies:", missing); | ||
| process.exit(1); | ||
| } | ||
| console.log(`ok: ${Object.keys(SPAWNED_PROXIES).length} proxies resolve in ${server}`); | ||
| EOF | ||
| for bin in "$res/cua-driver" "$res/OpenMausBot Speech.app/Contents/MacOS/speech-helper"; do | ||
| lipo -archs "$bin" | grep -q "x86_64" || { echo "::error::$bin lacks x86_64"; exit 1; } | ||
| lipo -archs "$bin" | grep -q "arm64" || { echo "::error::$bin lacks arm64"; exit 1; } | ||
| done | ||
| done | ||
|
|
||
| - name: Notarize all four artifacts | ||
| env: | ||
| APPLE_API_KEY_P8_BASE64: ${{ secrets.APPLE_API_KEY_P8_BASE64 }} | ||
| APPLE_API_KEY_ID: ${{ secrets.APPLE_API_KEY_ID }} | ||
| APPLE_API_ISSUER_ID: ${{ secrets.APPLE_API_ISSUER_ID }} | ||
| run: | | ||
| echo "$APPLE_API_KEY_P8_BASE64" | base64 --decode > "$RUNNER_TEMP/notary.p8" | ||
| for f in release/*.dmg release/*.zip; do | ||
| out=$(xcrun notarytool submit "$f" \ | ||
| --key "$RUNNER_TEMP/notary.p8" \ | ||
| --key-id "$APPLE_API_KEY_ID" \ | ||
| --issuer "$APPLE_API_ISSUER_ID" \ | ||
| --wait 2>&1) | ||
| echo "$out" | tail -2 | ||
| echo "$out" | grep -q "status: Accepted" || { echo "::error::notarization not Accepted for $f"; exit 1; } | ||
| done | ||
| rm "$RUNNER_TEMP/notary.p8" | ||
|
|
||
| - name: Staple, re-zip, regenerate blockmaps and the feed | ||
| run: | | ||
| xcrun stapler staple release/OpenMausBot-*-arm64.dmg | ||
| xcrun stapler staple release/OpenMausBot-*-x64.dmg | ||
| xcrun stapler staple release/mac-arm64/OpenMausBot.app | ||
| xcrun stapler staple release/mac/OpenMausBot.app | ||
| v=$(node -p "require('./package.json').version") | ||
| rm -f "release/OpenMausBot-$v-arm64.zip" "release/OpenMausBot-$v-x64.zip" | ||
| ditto -c -k --sequesterRsrc --keepParent release/mac-arm64/OpenMausBot.app "release/OpenMausBot-$v-arm64.zip" | ||
| ditto -c -k --sequesterRsrc --keepParent release/mac/OpenMausBot.app "release/OpenMausBot-$v-x64.zip" | ||
| AB=$(node -p "require('app-builder-bin').appBuilderPath") | ||
| for f in "release/OpenMausBot-$v-arm64.zip" "release/OpenMausBot-$v-x64.zip" \ | ||
| "release/OpenMausBot-$v-arm64.dmg" "release/OpenMausBot-$v-x64.dmg"; do | ||
| "$AB" blockmap -i "$f" -o "$f.blockmap" > /dev/null | ||
| done | ||
| # refreshes every hash from the post-staple bytes and refuses to | ||
| # finish unless the feed matches the files on disk | ||
| node scripts/regenerate-mac-feed.mjs | ||
|
|
||
| - name: Stable-named copies for the README's /latest/download links | ||
| run: | | ||
| v=$(node -p "require('./package.json').version") | ||
| cp "release/OpenMausBot-$v-arm64.dmg" release/OpenMausBot.dmg | ||
| cp "release/OpenMausBot-$v-x64.dmg" release/OpenMausBot-intel.dmg | ||
|
|
||
| - uses: actions/upload-artifact@v4 | ||
| with: | ||
| name: mac-release | ||
| path: | | ||
| release/*.dmg | ||
| release/*.zip | ||
| release/*.blockmap | ||
| release/latest-mac.yml | ||
| if-no-files-found: error | ||
| retention-days: 7 | ||
|
|
||
| windows: | ||
| name: Windows x64 | ||
| needs: prepare | ||
| runs-on: windows-latest | ||
| timeout-minutes: 30 | ||
| steps: | ||
| - uses: actions/checkout@v4 | ||
| with: | ||
| ref: ${{ needs.prepare.outputs.sha }} | ||
| persist-credentials: false | ||
| - uses: pnpm/action-setup@v4 | ||
| - uses: actions/setup-node@v4 | ||
| with: | ||
| node-version: 24 | ||
| cache: pnpm | ||
| - run: pnpm install --frozen-lockfile | ||
| - name: Clean generated output | ||
| shell: bash | ||
| run: rm -rf dist dist-server release | ||
| - run: pnpm package:win | ||
| - name: Verify the packaged tree and start the packaged server | ||
| shell: bash | ||
| run: | | ||
| res=release/win-unpacked/resources | ||
| [ -f "$res/server/index.js" ] || { echo "::error::missing server/index.js"; exit 1; } | ||
| [ -f "$res/ui/index.html" ] || { echo "::error::missing ui/index.html"; exit 1; } | ||
| [ -f "$res/app-update.yml" ] || { echo "::error::missing app-update.yml"; exit 1; } | ||
| grep -q "openmausbot-releases" "$res/app-update.yml" || { echo "::error::wrong update repo"; exit 1; } | ||
| if grep -q "publisherName" "$res/app-update.yml"; then | ||
| echo "::error::publisherName on an unsigned build breaks every update"; exit 1 | ||
| fi | ||
| HOME="$RUNNER_TEMP/smoke" USERPROFILE="$RUNNER_TEMP/smoke" OMB_PORT=21987 \ | ||
| node "$res/server/index.js" > "$RUNNER_TEMP/server.log" 2>&1 & | ||
| pid=$! | ||
| for _ in $(seq 1 90); do | ||
| curl -fsS --max-time 2 http://127.0.0.1:21987/api/health >/dev/null 2>&1 && { kill $pid; exit 0; } | ||
| kill -0 $pid 2>/dev/null || break | ||
| sleep 1 | ||
| done | ||
| echo "::error::the packaged server never served /api/health" | ||
| cat "$RUNNER_TEMP/server.log" || true | ||
| exit 1 | ||
| - name: Stable-named copy | ||
| shell: bash | ||
| run: | | ||
| v=$(node -p "require('./package.json').version") | ||
| cp "release/OpenMausBot-$v-setup.exe" release/OpenMausBot-setup.exe | ||
| - uses: actions/upload-artifact@v4 | ||
| with: | ||
| name: windows-release | ||
| path: | | ||
| release/*.exe | ||
| release/*.exe.blockmap | ||
| release/latest.yml | ||
| if-no-files-found: error | ||
| retention-days: 7 | ||
|
|
||
| linux: | ||
| name: Ubuntu 24.04 x64 | ||
| needs: prepare | ||
| runs-on: ubuntu-24.04 | ||
| timeout-minutes: 40 | ||
| steps: | ||
| - uses: actions/checkout@v4 | ||
| with: | ||
| ref: ${{ needs.prepare.outputs.sha }} | ||
| persist-credentials: false | ||
| - uses: pnpm/action-setup@v4 | ||
| - uses: actions/setup-node@v4 | ||
| with: | ||
| node-version: 24 | ||
| cache: pnpm | ||
| - name: Install package validation and native smoke tools | ||
| run: >- | ||
| sudo apt-get update && sudo apt-get install -y | ||
| at-spi2-core dbus-x11 desktop-file-utils libxi6 libxkbcommon0 squashfs-tools xvfb | ||
| - run: pnpm install --frozen-lockfile | ||
| - name: Clean generated output | ||
| run: pnpm clean | ||
| - name: Stage the pinned CUA runtime | ||
| run: pnpm build:cua:linux | ||
| - run: pnpm package:linux | ||
| - name: Smoke the packages | ||
| run: node scripts/smoke-linux-package.mjs | ||
| - name: Stable-named copies and checksums | ||
| run: | | ||
| v=$(node -p "require('./package.json').version") | ||
| cp "release/OpenMausBot-$v-amd64.deb" release/OpenMausBot-amd64.deb | ||
| cp "release/OpenMausBot-$v-x86_64.AppImage" release/OpenMausBot.AppImage | ||
| (cd release && sha256sum OpenMausBot-$v-amd64.deb OpenMausBot-$v-x86_64.AppImage > SHA256SUMS-ubuntu-x64.txt) | ||
| - uses: actions/upload-artifact@v4 | ||
| with: | ||
| name: linux-release | ||
| path: | | ||
| release/*.deb | ||
| release/*.AppImage | ||
| release/SHA256SUMS-ubuntu-x64.txt | ||
| release/latest-linux.yml | ||
| if-no-files-found: error | ||
| retention-days: 7 | ||
|
|
||
| assemble: | ||
| name: Assemble the draft and verify every feed | ||
| needs: [prepare, mac, windows, linux] | ||
| runs-on: ubuntu-24.04 | ||
| timeout-minutes: 30 | ||
| steps: | ||
| - uses: actions/download-artifact@v4 | ||
| with: | ||
| path: assets | ||
| merge-multiple: true | ||
| - name: Verify every feed hash against the actual bytes | ||
| run: | | ||
| cd assets | ||
| node --input-type=module - <<'EOF' | ||
| import { createHash } from "node:crypto"; | ||
| import { readFileSync, readdirSync, statSync, existsSync } from "node:fs"; | ||
| const sha512 = (f) => createHash("sha512").update(readFileSync(f)).digest("base64"); | ||
| let bad = 0; | ||
| for (const feed of ["latest-mac.yml", "latest.yml", "latest-linux.yml"]) { | ||
| if (!existsSync(feed)) continue; | ||
| const text = readFileSync(feed, "utf8"); | ||
| for (const [, url, hash, size] of text.matchAll(/url:\s+(\S+)[\s\S]*?sha512:\s+(\S+)\n\s+size:\s+(\d+)/g)) { | ||
| if (!existsSync(url)) { console.error(`MISSING ${url} (listed in ${feed})`); bad++; continue; } | ||
| const okHash = sha512(url) === hash; | ||
| const okSize = statSync(url).size === Number(size); | ||
| if (!okHash || !okSize) { console.error(`MISMATCH ${url} in ${feed}`); bad++; } | ||
| else console.log(`ok ${url}`); | ||
| } | ||
| } | ||
| if (bad) process.exit(1); | ||
| console.log(`\nall feeds verified; ${readdirSync(".").length} assets staged`); | ||
| EOF | ||
|
Comment on lines
+303
to
+324
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win Fail when any required feed is missing or empty. Line 312 skips a missing feed. The artifact upload can still succeed because the platform binaries match its upload paths. Increment 🤖 Prompt for AI Agents |
||
| - name: Create or update the draft with the complete asset set | ||
| env: | ||
| GH_TOKEN: ${{ secrets.RELEASES_PAT }} | ||
| VERSION: ${{ needs.prepare.outputs.version }} | ||
| SHA: ${{ needs.prepare.outputs.sha }} | ||
| run: | | ||
| cd assets | ||
| tag="v$VERSION" | ||
| if ! gh release view "$tag" --repo milind-soni/openmausbot-releases > /dev/null 2>&1; then | ||
| gh release create "$tag" --repo milind-soni/openmausbot-releases \ | ||
| --draft --title "OpenMausBot $VERSION" \ | ||
| --notes "Draft assembled by the release workflow from milind-soni/OpenMausBot@$SHA. Edit these notes, then publish." | ||
| fi | ||
| for f in *; do | ||
| gh release upload "$tag" "$f" --repo milind-soni/openmausbot-releases --clobber | ||
| done | ||
|
Comment on lines
+325
to
+340
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win 🧩 Analysis chain🏁 Script executed: #!/bin/bash
set -eu
printf '%s\n' '--- workflow files ---'
git ls-files '.github/workflows/*'
printf '%s\n' '--- release workflow outline ---'
if command -v ast-grep >/dev/null 2>&1; then
ast-grep outline .github/workflows/release.yml || true
fi
printf '%s\n' '--- relevant workflow references ---'
rg -n -C 6 'concurr|gh release|isDraft|draft|upload|release view|release create|needs\.' .github/workflows/release.ymlRepository: milind-soni/OpenMausBot Length of output: 7865 🌐 Web query:
💡 Result: In the GitHub CLI (gh), the --clobber flag for the gh release upload command is used to replace existing assets that share the same name as the files being uploaded [1][2]. It is important to note that this operation is not atomic [3]. When you use --clobber, the GitHub CLI deletes the existing asset before uploading the new one [1][3]. Consequently, if the upload process is interrupted or fails after the deletion but before the new asset is fully uploaded, the original asset will be lost [1][3][4]. The documentation has been updated to clarify this behavior, explicitly stating that it performs a "delete-and-re-upload" process rather than a simple overwrite [3]. Users should be aware of this risk when performing updates in environments where interruptions might occur [3][4]. If you encounter persistent issues with --clobber (such as unexpected 422 errors or asset conflicts), ensure that you are not accidentally triggering multiple simultaneous uploads for the same file, as platform-level API constraints can sometimes cause these attempts to fail [5][6]. Citations:
🌐 Web query:
💡 Result: In GitHub Actions, the Citations:
🏁 Script executed: #!/bin/bash
set -eu
python3 - <<'PY'
from pathlib import Path
import re
path = Path(".github/workflows/release.yml")
text = path.read_text()
lines = text.splitlines()
def line_number(fragment, start=0):
for i in range(start, len(lines)):
if fragment in lines[i]:
return i + 1
return None
early_guard = line_number("Refuse to overwrite a published release")
upload = line_number("gh release upload")
publish = line_number("gh release edit")
draft_checks = [
i + 1 for i, line in enumerate(lines)
if "isDraft" in line
]
concurrency_lines = [
i + 1 for i, line in enumerate(lines)
if re.match(r"^\s*concurrency\s*:", line)
]
print(f"early_guard_line={early_guard}")
print(f"first_upload_line={upload}")
print(f"publish_line={publish}")
print(f"isDraft_lines={draft_checks}")
print(f"concurrency_lines={concurrency_lines}")
print(f"guard_precedes_upload={early_guard is not None and upload is not None and early_guard < upload}")
print(f"draft_check_between_assemble_and_upload={any(293 <= n < upload for n in draft_checks)}")
print(f"workflow_or_job_concurrency_present={bool(concurrency_lines)}")
PYRepository: milind-soni/OpenMausBot Length of output: 380 Guard the release state before uploading assets. A user can publish the draft after the early check and before the upload loop. 🤖 Prompt for AI Agents |
||
| echo "asset count: $(gh release view "$tag" --repo milind-soni/openmausbot-releases --json assets --jq '.assets | length')" | ||
|
|
||
| - name: Publish (only when asked to) | ||
| if: ${{ inputs.publish }} | ||
| env: | ||
| GH_TOKEN: ${{ secrets.RELEASES_PAT }} | ||
| VERSION: ${{ needs.prepare.outputs.version }} | ||
| run: | | ||
| gh release edit "v$VERSION" --repo milind-soni/openmausbot-releases --draft=false --latest | ||
| # a draft that LOOKS published is the 0.1.24 failure mode — verify | ||
| gh release view "v$VERSION" --repo milind-soni/openmausbot-releases --json isDraft --jq .isDraft | grep -q false | ||
| echo "v$VERSION is live" | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🎯 Functional Correctness | 🟠 Major | ⚡ Quick win
Validate Windows proxy paths before accepting the package.
The Windows job only checks
/api/health. That endpoint can pass when a packaged helper path is unresolved.Run the same
SPAWNED_PROXIESexistence check that the macOS job runs before uploading the Windows artifact.🤖 Prompt for AI Agents