diff --git a/.github/workflows/release-dmg.yml b/.github/workflows/release-dmg.yml new file mode 100644 index 00000000..c6684731 --- /dev/null +++ b/.github/workflows/release-dmg.yml @@ -0,0 +1,189 @@ +name: Release DMG (hosted macOS) + +# Builds, signs and notarizes the canonical Vibecrafted_--.dmg +# on a GitHub-hosted macOS runner. The runner's home and workspace +# (/Users/runner, /Users/runner/work) identify nobody, so the payload-hygiene +# gate is told they are ephemeral; every other literal is still refused. +# +# Operator decision 2026-08-22: signing and notarization may run from a hosted +# runner, not only from the operator's machine. Publication to the GitHub +# release stays a separate, explicit step (publish-vibecrafted-release.sh). +# +# Secrets (repository β†’ Settings β†’ Secrets β†’ Actions): +# VC_CERT_P12_B64 base64 of Certificates.p12 (Developer ID Application) +# VC_CERT_PASSWORD password of that .p12 +# VC_SIGNING_IDENTITY "Developer ID Application: ()" +# VC_SIGNING_KEY vibecrafted-signing.key (release-output signature) +# VC_NOTARY_APPLE_ID / VC_NOTARY_TEAM_ID / VC_NOTARY_PASSWORD notarytool +# VC_FONT_PASSPHRASE decrypts assets/fonts/SpotMono.ttc.enc (licensed font, AES-256-CBC/PBKDF2) +# +# πš…πš’πš‹πšŽπšŒπš›πšŠπšπšπšŽπš. with AI Agents by Vetcoders (c)2024-2026 LibraxisAI + +on: + workflow_dispatch: + inputs: + ref: + description: vibecrafted ref to build (branch, tag or sha) + required: false + default: main + frame_ref: + description: vc-frame donor ref (develop is the integration line; main lags it) + required: false + default: develop + terminal_ref: + description: vc-terminal donor ref + required: false + default: master + notarize: + description: submit to Apple notary (false = sign only) + type: boolean + default: true + push: + tags: + - "v*" + +permissions: + contents: read + +concurrency: + group: release-dmg-${{ github.ref }} + cancel-in-progress: false + +jobs: + dmg: + runs-on: macos-15 + timeout-minutes: 180 + env: + KEYS: ${{ github.workspace }}/.keys + VIBECRAFTED_FRAME_REPO: ${{ github.workspace }}/vc-frame + VIBECRAFTED_TERMINAL_REPO: ${{ github.workspace }}/vc-terminal + # The hosted runner has no identity: its home and workspace are the same + # on every macos-15 runner. Declared here, not in the gate. + PAYLOAD_HYGIENE_EPHEMERAL_ROOTS: | + /Users/runner + ${{ github.workspace }} + # …and what the runner must still prove: the payload names neither the + # operator whose Developer ID signs it nor their workshop. + PAYLOAD_HYGIENE_EXTRA_LITERALS: | + /Users/polyversai + /Volumes/vc-workspace + steps: + - name: Check out vibecrafted + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + with: + path: vibecrafted + ref: ${{ inputs.ref || github.ref }} + fetch-depth: 0 + persist-credentials: false + + - name: Check out donors (vc-frame, vc-terminal) + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + with: + repository: vetcoders/vc-frame + ref: ${{ inputs.frame_ref || 'develop' }} + path: vc-frame + fetch-depth: 0 + persist-credentials: false + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + with: + repository: vetcoders/vc-terminal + ref: ${{ inputs.terminal_ref || 'master' }} + path: vc-terminal + fetch-depth: 0 + persist-credentials: false + + - name: Select Xcode + run: | + set -euo pipefail + sudo xcode-select -s "$(ls -d /Applications/Xcode*.app | sort -V | tail -1)/Contents/Developer" + xcodebuild -version + + - name: Install build tools + run: | + set -euo pipefail + brew install xcodegen shellcheck protobuf + # macos-15 images ship rustup; donors pin their toolchain via rust-toolchain.toml + rustup --version + rustup default stable + rustup target add wasm32-wasip1 wasm32-unknown-unknown + # Vibecrafted Server shell (leptos); version matches the operator machine + cargo install --locked cargo-leptos@0.3.7 + - uses: astral-sh/setup-uv@d0cc045d04ccac9d8b7881df0226f9e82c39688e # v6 + + - name: Materialize signing keys from secrets + env: + VC_CERT_P12_B64: ${{ secrets.VC_CERT_P12_B64 }} + VC_CERT_PASSWORD: ${{ secrets.VC_CERT_PASSWORD }} + VC_SIGNING_IDENTITY: ${{ secrets.VC_SIGNING_IDENTITY }} + VC_SIGNING_KEY: ${{ secrets.VC_SIGNING_KEY }} + VC_NOTARY_APPLE_ID: ${{ secrets.VC_NOTARY_APPLE_ID }} + VC_NOTARY_TEAM_ID: ${{ secrets.VC_NOTARY_TEAM_ID }} + VC_NOTARY_PASSWORD: ${{ secrets.VC_NOTARY_PASSWORD }} + VC_FONT_PASSPHRASE: ${{ secrets.VC_FONT_PASSPHRASE }} + run: | + set -euo pipefail + for v in VC_CERT_P12_B64 VC_CERT_PASSWORD VC_SIGNING_IDENTITY VC_SIGNING_KEY VC_NOTARY_APPLE_ID VC_NOTARY_TEAM_ID VC_NOTARY_PASSWORD VC_FONT_PASSPHRASE; do + [[ -n "${!v:-}" ]] || { echo "::error::missing secret $v"; exit 1; } + done + umask 077 + mkdir -p "$KEYS/fonts" + printf '%s' "$VC_CERT_P12_B64" | base64 --decode > "$KEYS/Certificates.p12" + printf '%s\n' "$VC_CERT_PASSWORD" > "$KEYS/cert_password.txt" + printf '%s\n' "$VC_SIGNING_IDENTITY" > "$KEYS/signing-identity.txt" + printf '%s\n' "$VC_SIGNING_KEY" > "$KEYS/vibecrafted-signing.key" + printf 'NOTARY_APPLE_ID=%s\nNOTARY_TEAM_ID=%s\nNOTARY_PASSWORD=%s\n' \ + "$VC_NOTARY_APPLE_ID" "$VC_NOTARY_TEAM_ID" "$VC_NOTARY_PASSWORD" > "$KEYS/.notary.env" + openssl enc -d -aes-256-cbc -pbkdf2 -iter 200000 \ + -in vibecrafted/assets/fonts/SpotMono.ttc.enc -out "$KEYS/fonts/SpotMono.ttc" \ + -pass env:VC_FONT_PASSPHRASE + file "$KEYS/Certificates.p12" "$KEYS/fonts/SpotMono.ttc" + + - name: Trust the Developer ID intermediate on the runner + # The .p12 carries the leaf; codesign still has to chain it to Apple's + # Developer ID G2 CA, which the hosted image does not ship. + run: | + set -euo pipefail + curl -fsSL -o DeveloperIDG2CA.cer https://www.apple.com/certificateauthority/DeveloperIDG2CA.cer + security import DeveloperIDG2CA.cer -k "$HOME/Library/Keychains/login.keychain-db" -T /usr/bin/codesign + rm -f DeveloperIDG2CA.cer + + - name: Build, sign, notarize + working-directory: vibecrafted + env: + VIBECRAFTED_KEYCHAIN_SEARCH_LIST: "1" + # --snapshot-donors: the tracked zellij-utils/assets/plugins/*.wasm + # blobs are build output of whichever machine last ran + # `make plugins-assets` (run #5 measured 411 operator-home paths + # reaching Contents/Helpers/vc-frame through include_bytes!). The + # builder recompiles them under its remaps, but only inside a donor + # snapshot it owns β€” on this runner the donors are throwaway anyway. + RELEASE_FLAGS: ${{ (github.event_name == 'workflow_dispatch' && inputs.notarize == false) && '--snapshot-donors --no-notarize' || '--snapshot-donors' }} + run: | + set -euo pipefail + rustup show active-toolchain || true + make release RELEASE_FLAGS="$RELEASE_FLAGS" KEYS="$KEYS" + ls -la dist/*.dmg dist/*.dmg.sha256 dist/release-output.json + + - name: Walk the shipped DMG through the hygiene gate once more + working-directory: vibecrafted + run: | + set -euo pipefail + dmg="$(ls dist/Vibecrafted_*.dmg | sort | tail -1)" + bash scripts/payload-hygiene-artifact.sh "$dmg" + spctl -a -t open --context context:primary-signature -v "$dmg" || true + + - name: Scrub keys + if: always() + run: rm -rf "$KEYS" + + - name: Upload DMG + receipts + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: vibecrafted-dmg-${{ github.run_id }} + if-no-files-found: error + retention-days: 30 + path: | + vibecrafted/dist/Vibecrafted_*.dmg + vibecrafted/dist/Vibecrafted_*.dmg.sha256 + vibecrafted/dist/release-output.json + vibecrafted/dist/release-output.json.sig diff --git a/assets/fonts/SpotMono.ttc.enc b/assets/fonts/SpotMono.ttc.enc new file mode 100644 index 00000000..57a9b8b9 Binary files /dev/null and b/assets/fonts/SpotMono.ttc.enc differ diff --git a/docs/RELEASE_KICKOFF.md b/docs/RELEASE_KICKOFF.md index 936053cb..108c84be 100644 --- a/docs/RELEASE_KICKOFF.md +++ b/docs/RELEASE_KICKOFF.md @@ -12,8 +12,17 @@ - Entry: bundled `vc-start` with durable `workspace_id` The donor repositories never publish an app, DMG, MSI, installer or update -channel. The root tag workflow is read-only. Apple signing, notarization and -publication run from the explicit macOS operator boundary: +channel. Apple signing and notarization run in one of two places; publication +stays an explicit operator step either way: + +- **Hosted runner** (`.github/workflows/release-dmg.yml`, macos-15): builds, + signs and notarizes `Vibecrafted_--.dmg` on every + `v*` tag or via `workflow_dispatch` (ref, donor refs, notarize on/off) and + uploads it as the `vibecrafted-dmg-` artifact together with + `release-output.json[.sig]`. The runner's home is declared ephemeral for the + payload-hygiene gate; the operator's account and checkout are still refused. + Nothing is published from CI. +- **Operator machine**: ```bash make release @@ -21,6 +30,9 @@ make portable GH_TOKEN=... make publish-release ``` +Either way `gh run download -n vibecrafted-dmg-` / `dist/` is what +`publish-vibecrafted-release.sh` takes to the GitHub release. + `make portable` needs neither signing identity nor notary account β€” it is a provenance-bound source distribution, so it builds anywhere `git` and `python3` do, and it re-validates the archive it just wrote before the bytes may leave the diff --git a/scripts/build-vibecrafted-release.sh b/scripts/build-vibecrafted-release.sh index 07f9cf52..276d2bf0 100755 --- a/scripts/build-vibecrafted-release.sh +++ b/scripts/build-vibecrafted-release.sh @@ -184,7 +184,12 @@ prepare_signing_identity() { # old `security default-keychain -d user -s "$TEMP_KEYCHAIN_PATH"` is what # made Codescribe (and everything else on the host) prompt for a uuidgen # password for the whole length of the release. - KEYCHAIN_SESSION_REGISTER_SEARCH_LIST=0 \ + # + # A hosted runner is the one place where registering it is right: nothing + # else runs there, and codesign resolves the Developer ID chain through the + # search list, not through --keychain alone (run 32597029908: identity + # present, "The specified item could not be found in the keychain"). + KEYCHAIN_SESSION_REGISTER_SEARCH_LIST="${VIBECRAFTED_KEYCHAIN_SEARCH_LIST:-0}" \ keychain_session_begin "$SIGNING_KEYCHAIN_LABEL" TEMP_KEYCHAIN_PATH="$KEYCHAIN_SESSION_PATH" temp_password="$(cat "$(keychain_session_password_file)")" diff --git a/scripts/lib/payload-hygiene.sh b/scripts/lib/payload-hygiene.sh index 8d92ad78..c246ca1b 100644 --- a/scripts/lib/payload-hygiene.sh +++ b/scripts/lib/payload-hygiene.sh @@ -61,6 +61,24 @@ payload_hygiene_topmost_host_root() { # Emits the workshop above each root as well: the topmost still-host-specific # ancestor subsumes every longer path under it, so one literal closes the whole # blind spot without drowning the report in near-duplicate matches. +# PAYLOAD_HYGIENE_EPHEMERAL_ROOTS β€” newline-separated absolute paths that +# identify nobody: the home and workspace of a hosted CI runner such as +# /Users/runner and /Users/runner/work. Every hosted macOS runner on earth has +# the same ones, so a payload that names them says nothing about who built it. +# This is NOT an allowlist of payload strings: the scanner still refuses every +# literal that survives, and a root is only ephemeral when the caller declares +# it so. Unset (the operator boundary) changes nothing. +payload_hygiene_is_ephemeral() { + local path="${1%/}" root + [[ -n "${PAYLOAD_HYGIENE_EPHEMERAL_ROOTS:-}" ]] || return 1 + while IFS= read -r root; do + root="${root%/}" + [[ -n "$root" && "$root" != "/" ]] || continue + [[ "$path" == "$root" || "$path" == "$root"/* ]] && return 0 + done <<< "$PAYLOAD_HYGIENE_EPHEMERAL_ROOTS" + return 1 +} + payload_hygiene_literals() { local root local -a ancestors=() @@ -79,6 +97,19 @@ payload_hygiene_literals() { done < <(payload_hygiene_topmost_host_root "$root") done + # PAYLOAD_HYGIENE_EXTRA_LITERALS β€” newline-separated literals a caller adds on + # top of the build-host set. A hosted runner declares its own roots ephemeral + # and then has nothing of its own to forbid; what it must still prove is that + # the payload does not name the OPERATOR whose keys sign it. The operator's + # home and workshop are those literals. + local -a extra=() + if [[ -n "${PAYLOAD_HYGIENE_EXTRA_LITERALS:-}" ]]; then + local line + while IFS= read -r line; do + [[ -n "$line" ]] && extra+=("$line") + done <<< "$PAYLOAD_HYGIENE_EXTRA_LITERALS" + fi + local candidate for candidate in \ "${HOME:-}" \ @@ -88,11 +119,13 @@ payload_hygiene_literals() { "${TERMINAL_REPO:-}" \ "${FRAME_REPO:-}" \ "${ancestors[@]+"${ancestors[@]}"}" \ + "${extra[@]+"${extra[@]}"}" \ "$@" do # `/` and the empty string would match the entire payload; the scanner # refuses them too, but not emitting them keeps the failure honest. [[ -n "$candidate" && "$candidate" != "/" ]] || continue + payload_hygiene_is_ephemeral "$candidate" && continue printf '%s\n' "${candidate%/}" done | sort -u }