Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions .github/actions/setup/action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,9 @@ runs:
using: composite
steps:
- name: Cache Hermit packages
uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: ~/.cache/hermit/pkg
path: ${{ runner.os == 'macOS' && '~/Library/Caches/hermit/pkg' || '~/.cache/hermit/pkg' }}
# Cache contents depend on which tools this job actually provisions.
# A faster JS-only job must not freeze an incomplete cache for Rust jobs.
key: hermit-${{ runner.os }}-${{ runner.arch }}-${{ github.job }}-${{ hashFiles('bin/**') }}
Expand All @@ -20,7 +20,7 @@ runs:
shell: bash
run: echo "path=$(pnpm store path --silent)" >> "$GITHUB_OUTPUT"
- name: Cache pnpm store
uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: ${{ steps.pnpm.outputs.path }}
key: pnpm-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('pnpm-lock.yaml', 'bin/.pnpm-*.pkg') }}
Expand All @@ -35,7 +35,7 @@ runs:
run: node -p "'version=' + require('@playwright/test/package.json').version" >> "$GITHUB_OUTPUT"
- name: Cache Playwright engines
if: inputs.browsers == 'true'
uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: ~/.cache/ms-playwright
key: playwright-${{ runner.os }}-${{ runner.arch }}-${{ steps.playwright.outputs.version }}-chromium-webkit
Expand Down
153 changes: 153 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
name: macOS prerelease

on:
workflow_dispatch:
schedule:
- cron: "17 */6 * * *"

permissions:
contents: read

concurrency:
group: macos-prerelease
cancel-in-progress: false

jobs:
build:
if: github.repository == 'block/buzz-app'
name: Build, sign, and notarize
runs-on: macos-latest
timeout-minutes: 120
permissions:
contents: read
id-token: write
outputs:
version: ${{ steps.version.outputs.version }}
env:
CI: "true"
CARGO_TERM_COLOR: always
steps:
- name: Require main and signing configuration
env:
SOURCE_REF: ${{ github.ref }}
SIGNING_ROLE: ${{ secrets.OSX_CODESIGN_ROLE }}
SIGNING_BUCKET: ${{ secrets.CODESIGN_S3_BUCKET }}
run: |
test "$SOURCE_REF" = refs/heads/main || { echo '::error::Releases must run from main'; exit 1; }
test -n "$SIGNING_ROLE" && test -n "$SIGNING_BUCKET" || { echo '::error::Set OSX_CODESIGN_ROLE and CODESIGN_S3_BUCKET'; exit 1; }

- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- uses: ./.github/actions/setup
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
with:
key: macos-prerelease
cache-all-crates: true

- name: Set preview version
id: version
run: |
node --input-type=module <<'JS'
import { readFileSync, writeFileSync, appendFileSync } from 'node:fs';
const config = JSON.parse(readFileSync('src-tauri/tauri.conf.json', 'utf8'));
const base = config.version.split('-')[0];
if (!/^\d+\.\d+\.\d+$/.test(base)) throw new Error('Expected a numeric Tauri version');
const version = `${base}-preview.${process.env.GITHUB_RUN_NUMBER}.${process.env.GITHUB_RUN_ATTEMPT}`;
writeFileSync(`${process.env.RUNNER_TEMP}/release.json`, JSON.stringify({ version, bundle: { createUpdaterArtifacts: false } }));
appendFileSync(process.env.GITHUB_OUTPUT, `version=${version}\n`);
JS

- name: Build unsigned app and DMG
run: |
rm -rf target/release/bundle
pnpm tauri build --ci --no-sign --bundles dmg --config "$RUNNER_TEMP/release.json" -- --locked
env:
TAURI_BUNDLER_DMG_IGNORE_CI: "true"

- name: Locate unsigned DMG
id: unsigned
run: |
shopt -s nullglob
dmgs=(target/release/bundle/dmg/*.dmg)
test "${#dmgs[@]}" -eq 1 || { echo '::error::Expected exactly one DMG'; exit 1; }
echo "path=${dmgs[0]}" >> "$GITHUB_OUTPUT"

- name: Sign and notarize
id: codesign
uses: block/apple-codesign-action@679535d1ab7c5a7c18e6f9afcba3464512cc3dde # v1.1.0
with:
osx-codesign-role: ${{ secrets.OSX_CODESIGN_ROLE }}
codesign-s3-bucket: ${{ secrets.CODESIGN_S3_BUCKET }}
unsigned-artifact-path: ${{ steps.unsigned.outputs.path }}
artifact-name: buzz-app-${{ github.run_id }}-${{ github.run_attempt }}-arm64

- name: Verify release DMG and bundled runtime
env:
SIGNED_DMG: ${{ steps.codesign.outputs.signed-dmg-path }}
run: |
app_dir=$(mktemp -d "$RUNNER_TEMP/verify-release.XXXXXX")
hdiutil attach "$SIGNED_DMG" -readonly -nobrowse -mountpoint "$app_dir" >/dev/null
trap 'hdiutil detach "$app_dir" >/dev/null' EXIT
app="$app_dir/Buzz Foundation.app"
codesign --verify --deep --strict --verbose=2 \
-R '=anchor apple generic and certificate 1[field.1.2.840.113635.100.6.2.6] exists and certificate leaf[field.1.2.840.113635.100.6.1.13] exists and certificate leaf[subject.OU] = EYF346PHUG' "$app"
spctl --assess --type execute --verbose=4 "$app"
xcrun stapler validate "$app"
node --input-type=module - "$app/Contents/Resources/agent-runtime" <<'JS'
import { readFileSync, lstatSync } from 'node:fs';
import assert from 'node:assert/strict';
const directory = process.argv[2];
const source = JSON.parse(readFileSync('runtime/agent-runtime.json', 'utf8'));
const manifest = JSON.parse(readFileSync(`${directory}/manifest.json`, 'utf8'));
assert.equal(manifest.version, 1);
assert.equal(manifest.revision, source.revision);
assert.equal(manifest.target, 'aarch64-apple-darwin');
assert.deepEqual(Object.keys(manifest.files).sort(), [...source.tools].sort());
for (const name of source.tools) {
const path = `${directory}/${name}`;
assert.ok(lstatSync(path).isFile(), `${name} must be a regular file`);
assert.ok(lstatSync(path).mode & 0o111, `${name} must be executable`);
}
JS

- name: Stage release asset and checksum
env:
SIGNED_DMG: ${{ steps.codesign.outputs.signed-dmg-path }}
VERSION: ${{ steps.version.outputs.version }}
run: |
mkdir release-assets
cp "$SIGNED_DMG" "release-assets/Buzz_${VERSION}_aarch64.dmg"
cd release-assets
shasum -a 256 ./*.dmg > SHA256SUMS

- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: signed-macos-release
path: release-assets/
if-no-files-found: error
retention-days: 7

publish:
name: Publish GitHub prerelease
needs: build
runs-on: ubuntu-latest
timeout-minutes: 5
permissions:
contents: write
steps:
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: signed-macos-release
path: release-assets
- name: Publish signed DMG
env:
GH_TOKEN: ${{ github.token }}
GH_REPO: ${{ github.repository }}
SOURCE_SHA: ${{ github.sha }}
VERSION: ${{ needs.build.outputs.version }}
run: |
gh release create "v$VERSION" release-assets/* \
--target "$SOURCE_SHA" --prerelease --latest=false \
--title "Buzz $VERSION (macOS preview)" \
--notes "Apple Silicon macOS preview from commit $SOURCE_SHA. Built, signed, and notarized by Actions run $GITHUB_RUN_ID."
101 changes: 71 additions & 30 deletions crates/agent-controller/src/bundle.rs
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,13 @@ pub struct RuntimeBundle {
}
impl RuntimeBundle {
pub fn new(directory: PathBuf) -> Result<Self> {
Self::load(directory, verify_signed_resources)
}

fn load(
directory: PathBuf,
verify_signature: impl FnOnce(&Path) -> Result<()>,
) -> Result<Self> {
if !directory.is_absolute() {
return Err("Runtime bundle path must be absolute".into());
}
Expand All @@ -50,53 +57,82 @@ impl RuntimeBundle {
{
return Err("Runtime target/revision does not match this app".into());
}
let bundle = Self {
let mut bundle = Self {
directory,
files: manifest.files,
};
let mut observed = BTreeMap::new();
for name in source.tools {
bundle.executable(&name)?;
let filename = filename(&name);
if !bundle.files.contains_key(&filename) {
return Err("Required runtime tool is absent from the manifest".into());
}
observed.insert(
filename.clone(),
executable_hash(&bundle.directory.join(filename))?,
);
}
if observed != bundle.files {
verify_signature(&bundle.directory)?;
}
// Keep final bytes in memory so every launch still detects later changes.
bundle.files = observed;
Ok(bundle)
}
pub(crate) fn executable(&self, name: &str) -> Result<PathBuf> {
let filename = if cfg!(windows) {
format!("{name}.exe")
} else {
name.into()
};
let filename = filename(name);
let expected = self
.files
.get(&filename)
.ok_or("Required runtime tool is absent from the manifest")?;
let path = self.directory.join(filename);
let meta = std::fs::symlink_metadata(&path)
.map_err(|_| "Required runtime executable is missing")?;
if !meta.is_file() {
return Err("Runtime executable must be a regular file, not a link".into());
}
crate::runtime::executable(&path)?;
let mut file =
std::fs::File::open(&path).map_err(|_| "Could not read runtime executable")?;
let mut digest = Sha256::new();
let mut bytes = [0u8; 65536];
loop {
let n = file
.read(&mut bytes)
.map_err(|_| "Could not verify runtime executable")?;
if n == 0 {
break;
}
digest.update(&bytes[..n]);
}
if format!("{:x}", digest.finalize()) != *expected {
return Err(
"Runtime executable failed its integrity check; rebuild the app resources".into(),
);
if executable_hash(&path)? != *expected {
return Err(INTEGRITY_ERROR.into());
}
Ok(path)
}
}
const INTEGRITY_ERROR: &str =
"Runtime executable failed its integrity check; rebuild the app resources";

fn filename(name: &str) -> String {
if cfg!(windows) {
format!("{name}.exe")
} else {
name.into()
}
}

fn executable_hash(path: &Path) -> Result<String> {
let meta =
std::fs::symlink_metadata(path).map_err(|_| "Required runtime executable is missing")?;
if !meta.is_file() {
return Err("Runtime executable must be a regular file, not a link".into());
}
crate::runtime::executable(path)?;
let mut file = std::fs::File::open(path).map_err(|_| "Could not read runtime executable")?;
let mut digest = Sha256::new();
let mut bytes = [0u8; 65536];
loop {
let n = file
.read(&mut bytes)
.map_err(|_| "Could not verify runtime executable")?;
if n == 0 {
break;
}
digest.update(&bytes[..n]);
}
Ok(format!("{:x}", digest.finalize()))
}

#[cfg(target_os = "macos")]
mod macos;
#[cfg(target_os = "macos")]
fn verify_signed_resources(directory: &Path) -> Result<()> {
let executable = std::env::current_exe().map_err(|_| INTEGRITY_ERROR)?;
macos::verify(directory, &executable, macos::REQUIREMENT)
}

fn regular_directory(path: &Path) -> Result<()> {
let meta = std::fs::symlink_metadata(path)
.map_err(|_| "Agent runtime resource directory is missing")?;
Expand All @@ -106,3 +142,8 @@ fn regular_directory(path: &Path) -> Result<()> {
Err("Agent runtime resources cannot be a link".into())
}
}

#[cfg(not(target_os = "macos"))]
fn verify_signed_resources(_: &Path) -> Result<()> {
Err(INTEGRITY_ERROR.into())
}
Loading
Loading