From 47dccfac54b5a4fa61be167ca536bb859f3d2be5 Mon Sep 17 00:00:00 2001 From: Luis Padron Date: Fri, 25 Sep 2026 11:16:04 -0400 Subject: [PATCH 1/2] ci: add scheduled macOS test prereleases Signed-off-by: Luis Padron --- .github/actions/setup/action.yml | 8 +- .github/workflows/release.yml | 154 +++++++++++++++++++++++++++++++ docs/releases.md | 26 ++++++ 3 files changed, 184 insertions(+), 4 deletions(-) create mode 100644 .github/workflows/release.yml create mode 100644 docs/releases.md diff --git a/.github/actions/setup/action.yml b/.github/actions/setup/action.yml index 303a60621..26a846f8b 100644 --- a/.github/actions/setup/action.yml +++ b/.github/actions/setup/action.yml @@ -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/**') }} @@ -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') }} @@ -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 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 000000000..fd66b3b20 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,154 @@ +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 "$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 { createHash } from 'node:crypto'; + 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`); + const hash = createHash('sha256').update(readFileSync(path)).digest('hex'); + assert.equal(hash, manifest.files[name], `${name} failed its runtime integrity check`); + } + 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." diff --git a/docs/releases.md b/docs/releases.md new file mode 100644 index 000000000..de6372941 --- /dev/null +++ b/docs/releases.md @@ -0,0 +1,26 @@ +# macOS test releases + +The **macOS prerelease** workflow builds and signs Apple Silicon test builds from +`main`. It publishes a DMG and `SHA256SUMS` as a GitHub prerelease, tagged +`v-preview..` at the built commit. + +Runs are scheduled at **00:17, 06:17, 12:17, and 18:17 UTC**. To start one manually: + +```sh +gh workflow run release.yml --repo block/buzz-app --ref main +``` + +These builds use `macos-latest` and the repository's pinned toolchain. They do not +generate Tauri updater artifacts or upload to the legacy `block/buzz` updater. + +## Prerequisites + +Deploy [the signing infrastructure](https://github.com/squareup/tf-mobuild-workers/pull/1398) +and set these repository Actions secrets: + +- `OSX_CODESIGN_ROLE`: ARN of `block-buzz-app-codesign-role`. +- `CODESIGN_S3_BUCKET`: `block-buzz-app-artifacts-bucket-`. + +**Signing is blocked:** the runtime manifest contains hashes of binaries before +signing changes them. Resolve that mismatch before merging. The workflow checks +signatures, notarization, and runtime hashes before publishing a release. From 942a981203472182549579fb9744b6956bbbc8fe Mon Sep 17 00:00:00 2001 From: Luis Padron Date: Fri, 25 Sep 2026 13:53:35 -0400 Subject: [PATCH 2/2] fix: verify signed macOS runtime resources Signed-off-by: Luis Padron --- .github/workflows/release.yml | 7 +- crates/agent-controller/src/bundle.rs | 101 +++++++++---- crates/agent-controller/src/bundle/macos.rs | 153 ++++++++++++++++++++ docs/agent-control.md | 6 +- docs/releases.md | 9 +- 5 files changed, 237 insertions(+), 39 deletions(-) create mode 100644 crates/agent-controller/src/bundle/macos.rs diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index fd66b3b20..dfd9fd7e9 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -90,12 +90,12 @@ jobs: 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 "$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 { createHash } from 'node:crypto'; import assert from 'node:assert/strict'; const directory = process.argv[2]; const source = JSON.parse(readFileSync('runtime/agent-runtime.json', 'utf8')); @@ -107,8 +107,7 @@ jobs: for (const name of source.tools) { const path = `${directory}/${name}`; assert.ok(lstatSync(path).isFile(), `${name} must be a regular file`); - const hash = createHash('sha256').update(readFileSync(path)).digest('hex'); - assert.equal(hash, manifest.files[name], `${name} failed its runtime integrity check`); + assert.ok(lstatSync(path).mode & 0o111, `${name} must be executable`); } JS diff --git a/crates/agent-controller/src/bundle.rs b/crates/agent-controller/src/bundle.rs index 44e0ef5f5..9de78147d 100644 --- a/crates/agent-controller/src/bundle.rs +++ b/crates/agent-controller/src/bundle.rs @@ -26,6 +26,13 @@ pub struct RuntimeBundle { } impl RuntimeBundle { pub fn new(directory: PathBuf) -> Result { + Self::load(directory, verify_signed_resources) + } + + fn load( + directory: PathBuf, + verify_signature: impl FnOnce(&Path) -> Result<()>, + ) -> Result { if !directory.is_absolute() { return Err("Runtime bundle path must be absolute".into()); } @@ -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 { - 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 { + 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")?; @@ -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()) +} diff --git a/crates/agent-controller/src/bundle/macos.rs b/crates/agent-controller/src/bundle/macos.rs new file mode 100644 index 000000000..636374d83 --- /dev/null +++ b/crates/agent-controller/src/bundle/macos.rs @@ -0,0 +1,153 @@ +use super::{Result, INTEGRITY_ERROR}; +use std::path::Path; +use std::process::Command; + +// The Block Developer ID used by the shared signing service. +pub(super) const REQUIREMENT: &str = "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"; + +pub(super) fn verify(directory: &Path, executable: &Path, requirement: &str) -> Result<()> { + let executable = executable.canonicalize().map_err(|_| INTEGRITY_ERROR)?; + let macos = executable.parent().ok_or(INTEGRITY_ERROR)?; + let contents = macos.parent().ok_or(INTEGRITY_ERROR)?; + let app = contents.parent().ok_or(INTEGRITY_ERROR)?; + if macos.file_name() != Some("MacOS".as_ref()) + || contents.file_name() != Some("Contents".as_ref()) + || app.extension() != Some("app".as_ref()) + || directory.canonicalize().map_err(|_| INTEGRITY_ERROR)? + != contents.join("Resources/agent-runtime") + { + return Err(INTEGRITY_ERROR.into()); + } + // Resources (including the manifest and tools) must match the enclosing seal. + let output = Command::new("/usr/bin/codesign") + .args(["--verify", "--deep", "--strict", "-R"]) + .arg(format!("={requirement}")) + .arg(app) + .output() + .map_err(|_| INTEGRITY_ERROR)?; + if !output.status.success() { + return Err(INTEGRITY_ERROR.into()); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::bundle::{executable_hash, RuntimeBundle, Source}; + use serde_json::json; + use std::collections::BTreeMap; + use std::fs; + use std::path::PathBuf; + + struct Fixture { + _temp: tempfile::TempDir, + app: PathBuf, + executable: PathBuf, + directory: PathBuf, + } + impl Fixture { + fn new() -> Self { + let temp = tempfile::tempdir().unwrap(); + let app = temp.path().join("Fixture.app"); + let executable = app.join("Contents/MacOS/Fixture"); + let directory = app.join("Contents/Resources/agent-runtime"); + fs::create_dir_all(executable.parent().unwrap()).unwrap(); + fs::create_dir_all(&directory).unwrap(); + fs::copy("/usr/bin/true", &executable).unwrap(); + fs::write(app.join("Contents/Info.plist"), r#"CFBundleExecutableFixtureCFBundleIdentifierdev.buzz.runtime-fixtureCFBundlePackageTypeAPPL"#).unwrap(); + let source: Source = + serde_json::from_str(include_str!("../../../../runtime/agent-runtime.json")) + .unwrap(); + let mut files = BTreeMap::new(); + for name in source.tools { + let path = directory.join(&name); + fs::copy("/usr/bin/true", &path).unwrap(); + files.insert(name, executable_hash(&path).unwrap()); + sign(&path); + } + fs::write( + directory.join("manifest.json"), + serde_json::to_vec(&json!({ + "version": 1, "revision": source.revision, + "target": env!("BUZZ_RUNTIME_TARGET"), "files": files, + })) + .unwrap(), + ) + .unwrap(); + Self { + _temp: temp, + app, + executable, + directory, + } + } + fn load(&self) -> Result { + // Exercise the real resource seal without needing a Developer ID key. + RuntimeBundle::load(self.directory.clone(), |directory| { + verify(directory, &self.executable, "true") + }) + } + } + fn sign(path: &Path) { + let output = Command::new("/usr/bin/codesign") + .args([ + "--force", + "--sign", + "-", + "--identifier", + "dev.buzz.runtime-fixture", + ]) + .arg(path) + .output() + .unwrap(); + assert!( + output.status.success(), + "{}", + String::from_utf8_lossy(&output.stderr) + ); + } + + #[test] + fn signed_resources_replace_pre_sign_hashes_but_detect_later_changes() { + let fixture = Fixture::new(); + assert!(fixture.load().is_err(), "unsigned app must fail"); + sign(&fixture.app); + let bundle = fixture.load().unwrap(); + assert!(bundle.executable("buzz-agent").is_ok()); + assert!( + verify(&fixture.directory, &fixture.executable, REQUIREMENT).is_err(), + "production must reject ad-hoc signatures" + ); + fs::write(fixture.directory.join("buzz-agent"), "tampered").unwrap(); + assert!(bundle.executable("buzz-agent").is_err()); + assert!(fixture.load().is_err(), "tampered seal must fail"); + } + + #[test] + fn signature_cannot_authorize_another_runtime_or_wrong_manifest() { + let fixture = Fixture::new(); + sign(&fixture.app); + let other = Fixture::new(); + assert!(verify(&other.directory, &fixture.executable, "true").is_err()); + assert!(verify(&fixture.directory, Path::new("/usr/bin/true"), "true").is_err()); + let manifest_path = fixture.directory.join("manifest.json"); + let original: serde_json::Value = + serde_json::from_slice(&fs::read(&manifest_path).unwrap()).unwrap(); + for field in ["revision", "target", "files"] { + let mut manifest = original.clone(); + manifest[field] = if field == "files" { + let mut files = original["files"].clone(); + let hash = files.as_object_mut().unwrap().remove("buzz-agent").unwrap(); + files["unexpected"] = hash; + files + } else { + json!("wrong") + }; + fs::write(&manifest_path, serde_json::to_vec(&manifest).unwrap()).unwrap(); + sign(&fixture.app); + assert!(verify(&fixture.directory, &fixture.executable, "true").is_ok()); + assert!(fixture.load().is_err(), "signed invalid {field} must fail"); + } + } +} diff --git a/docs/agent-control.md b/docs/agent-control.md index eae27d49a..446e0ac8a 100644 --- a/docs/agent-control.md +++ b/docs/agent-control.md @@ -335,8 +335,10 @@ to the same immutable source revision as the native library. The build script us and stages binaries plus revision/target/SHA256 manifest in `src-tauri/resources/agent-runtime`. Native build copies them to `target/debug/agent-runtime`. Generated binaries/manifest are not committed. -Startup verifies the exact tool set, target, revision and file hashes; required -launch tools are rehashed before spawn. No PATH/old-bundle fallback or runtime +Startup verifies the exact tool set, target, revision and file hashes. Packaged +macOS apps may accept signing-induced hash changes only when the runtime belongs +to the running app and its resource seal verifies under Block's Developer ID. +The final hashes are retained in memory; required launch tools are rehashed before spawn. No PATH/old-bundle fallback or runtime download. The manifest detects corrupt/mixed resources, not a same-user attacker who can replace the app and manifest. Inputs are immutable, not a promise of bit-identical machine-independent binaries. This build is not a signed installer. diff --git a/docs/releases.md b/docs/releases.md index de6372941..607e0d658 100644 --- a/docs/releases.md +++ b/docs/releases.md @@ -21,6 +21,9 @@ and set these repository Actions secrets: - `OSX_CODESIGN_ROLE`: ARN of `block-buzz-app-codesign-role`. - `CODESIGN_S3_BUCKET`: `block-buzz-app-artifacts-bucket-`. -**Signing is blocked:** the runtime manifest contains hashes of binaries before -signing changes them. Resolve that mismatch before merging. The workflow checks -signatures, notarization, and runtime hashes before publishing a release. +The manifest records hashes before signing. Packaged macOS apps accept changed +hashes only after verifying their enclosing app's resource seal and Block Developer +ID signature. The app retains the signed files' hashes in memory and checks them +before each launch. Development builds and other platforms still require the +manifest hashes to match. The release workflow verifies the signed seal, +notarization, and manifest identity before publishing.