Skip to content
Open
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
189 changes: 189 additions & 0 deletions .github/workflows/release-dmg.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,189 @@
name: Release DMG (hosted macOS)

# Builds, signs and notarizes the canonical Vibecrafted_<version>-<date>-<sha8>.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: <name> (<team>)"
# 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
Comment on lines +73 to +77

- 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
Comment on lines +97 to +99

- 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"
Comment on lines +125 to +135
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
Comment on lines +146 to +148

- 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
Binary file added assets/fonts/SpotMono.ttc.enc
Binary file not shown.
16 changes: 14 additions & 2 deletions docs/RELEASE_KICKOFF.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,15 +12,27 @@
- 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_<version>-<YYYYMMDD>-<sha8>.dmg` on every
`v*` tag or via `workflow_dispatch` (ref, donor refs, notarize on/off) and
uploads it as the `vibecrafted-dmg-<run_id>` 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
make portable
GH_TOKEN=... make publish-release
```

Either way `gh run download -n vibecrafted-dmg-<run_id>` / `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
Expand Down
7 changes: 6 additions & 1 deletion scripts/build-vibecrafted-release.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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)")"
Expand Down
33 changes: 33 additions & 0 deletions scripts/lib/payload-hygiene.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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%/}"
Comment on lines +71 to +75
[[ -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=()
Expand All @@ -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:-}" \
Expand All @@ -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
}
Expand Down
Loading