Two related changes to the public release path, both of which leave the published asset contract byte-identical. Build the matrix, publish only what is proven: - portal-release.yml gains a `preview` job building macOS x86_64, Linux x86_64, and Windows x86_64 from the exact release tag. Each target records a preview-manifest.json with per-artifact SHA-256, is attested, and is retained only as a 14-day Actions artifact. The job holds `contents: read`, so it is structurally incapable of adding a release asset, and it is not a dependency of `publish`. - ci.yml gains `portal-target-matrix`, compile-checking macOS x86_64 and Windows x86_64 under both documented pointer policies so a preview target cannot rot unnoticed. Windows had never been compiled anywhere before. - Records the arm64-only publishing decision that issue #5 asks for, and the Windows enrollment-storage gap: secure_config_dir and read_bounded_file gate their ownership check, 0o700 tightening, and O_NOFOLLOW open behind cfg(unix), so a Windows build has no equivalent protection. Distribute Portal ad-hoc signed rather than Developer ID signed: There is no Apple Developer Program membership and no free path to notarization, so trust moves from Gatekeeper to provenance: a GitHub/Sigstore attestation bound to the exact workflow, tag ref, and source commit, plus the published SHA-256. The manifest (format 2) records signing="adhoc" and notarized=false instead of claiming verification the artifact does not have. - package-macos-client.sh takes a required --signing adhoc|developer-id with no default. Ad-hoc mode refuses to build when any APPLE_* credential is present, requires the arm64 ad-hoc signature that macOS needs to execute at all, and proves the negatives: no authority chain, no TeamIdentifier, no staple. - Removes certificate import, the ephemeral keychain, and all six Apple secrets from the workflows, along with release/portal-apple-team-id.txt. - The site states the un-notarized first-launch consequence, and verify-website.sh now fails if any surface claims Developer ID notarization. - --signing developer-id is preserved for the day a membership exists. It is not exercised by CI and must be re-validated end to end before it is trusted. Full shell suite, portal-release tests, website gate, and CI cross-build policy all pass. No Rust changed.
- commit
- 9588dac08f7119613ef236a051b798c70cfc35c9
- parent
- a3af7ab2d9a9b1d0411f7af7d9b6b2ade6350d90
- author
- Dami <dami.etoile@gmail.com>
| M | .github/ | 28 | ++++ |
| M | .github/ | 217 | ++++++++++++++++++++++-------- |
| M | .github/ | 10 | +- |
| M | AGENTS.md | 25 | ++-- |
| M | README.md | 12 | +- |
| M | docs/ | 45 | ++++++- |
| M | docs/ | 129 | +++++++++++------- |
| M | docs/ | 15 | +- |
| M | docs/ | 58 | ++++++-- |
| A | docs/ | 195 | +++++++++++++++++++++++++++ |
| D | release/ | 1 | - |
| M | scripts/ | 162 | +++++++++++++++------- |
| M | scripts/ | 15 | +-- |
| M | scripts/ | 4 | +- |
| M | scripts/ | 25 | +++- |
| M | scripts/ | 59 | ++++++-- |
| M | scripts/ | 20 | ++- |
| M | scripts/ | 8 | + |
| M | website/ | 4 | +- |
| M | website/ | 1 | + |
@@ -50,6 +50,34 @@ jobs:
5050 python3 scripts/package-decky-plugin.py "$RUNNER_TEMP/decky-artifacts"5151 python3 -m unittest scripts.tests.test_decky_package -v5252 53+ portal-target-matrix:54+ name: Portal ${{ matrix.target }} compile guard55+ runs-on: ${{ matrix.os }}56+ timeout-minutes: 4057+ strategy:58+ fail-fast: false59+ matrix:60+ include:61+ - os: macos-2662+ target: x86_64-apple-darwin63+ - os: windows-202564+ target: x86_64-pc-windows-msvc65+ steps:66+ - name: Check out repository67+ uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.168+69+ - name: Add the release matrix target70+ run: rustup target add ${{ matrix.target }}71+72+ # These targets are built and attested for every release but are never73+ # published, so nothing else in CI would notice them breaking. This job74+ # keeps them compiling under both documented pointer policies.75+ - name: Compile Portal under both pointer policies76+ run: |77+ cargo check --locked -p portal --all-targets --target ${{ matrix.target }}78+ cargo check --locked -p portal --all-targets --target ${{ matrix.target }} \79+ --features experimental-non-macos-pointer-capture80+5381 demo-gate:5482 name: Complete demo gate5583 runs-on: ubuntu-24.04@@ -37,8 +37,163 @@ jobs:
3737 ./scripts/tests/portal-release.sh3838 ./scripts/verify-website.sh3939 40+ preview:41+ name: Build unpublished ${{ matrix.target }} preview42+ needs: policy43+ runs-on: ${{ matrix.os }}44+ timeout-minutes: 9045+ permissions:46+ attestations: write47+ contents: read48+ id-token: write49+ strategy:50+ fail-fast: false51+ matrix:52+ include:53+ - os: macos-2654+ target: x86_64-apple-darwin55+ platform: macos56+ bundles: app,dmg57+ - os: ubuntu-24.0458+ target: x86_64-unknown-linux-gnu59+ platform: linux60+ bundles: deb,rpm,appimage61+ - os: windows-202562+ target: x86_64-pc-windows-msvc63+ platform: windows64+ bundles: msi,nsis65+ env:66+ RELEASE_TAG: ${{ inputs.tag }}67+ steps:68+ - name: Check out exact tag69+ uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.170+ with:71+ ref: ${{ inputs.tag }}72+ fetch-depth: 073+74+ - name: Require workflow execution from the exact tag75+ shell: bash76+ run: |77+ test "$GITHUB_REF" = "refs/tags/$RELEASE_TAG"78+ test "$GITHUB_SHA" = "$(git rev-parse HEAD)"79+80+ - name: Install Linux build dependencies81+ if: matrix.platform == 'linux'82+ run: |83+ sudo apt-get update84+ sudo apt-get install --yes --no-install-recommends \85+ build-essential \86+ libayatana-appindicator3-dev \87+ librsvg2-dev \88+ libssl-dev \89+ libudev-dev \90+ libwebkit2gtk-4.1-dev \91+ libxdo-dev \92+ pkg-config93+94+ - name: Install pinned Tauri CLI95+ shell: bash96+ run: |97+ rustup target add "${{ matrix.target }}"98+ cargo install tauri-cli --version 2.11.3 --locked99+100+ # No optional features: a preview build must differ from the published101+ # macOS arm64 release only by target and by the absence of signing.102+ - name: Build unsigned preview bundles103+ shell: bash104+ run: |105+ cargo tauri build --locked --ci \106+ --target "${{ matrix.target }}" \107+ --bundles "${{ matrix.bundles }}"108+109+ - name: Collect preview artifacts and digests110+ id: collect111+ shell: bash112+ env:113+ TARGET: ${{ matrix.target }}114+ PLATFORM: ${{ matrix.platform }}115+ run: |116+ digest() {117+ if command -v sha256sum >/dev/null 2>&1; then118+ sha256sum "$1" | awk '{ print $1 }'119+ else120+ shasum -a 256 "$1" | awk '{ print $1 }'121+ fi122+ }123+124+ version="${RELEASE_TAG#v}"125+ staging="$RUNNER_TEMP/portal-preview"126+ mkdir -p "$staging"127+ bundle_root="target/$TARGET/release/bundle"128+ test -d "$bundle_root" || {129+ echo "the bundler produced no output at $bundle_root" >&2130+ exit 1131+ }132+133+ entries=""134+ count=0135+ while IFS= read -r artifact; do136+ name="$(basename "$artifact")"137+ # The manifest is assembled textually, so refuse any name that138+ # could alter its structure rather than escaping after the fact.139+ printf '%s' "$name" | grep -Eq '^[A-Za-z0-9][A-Za-z0-9._-]*$' || {140+ echo "unsafe preview artifact name: $name" >&2141+ exit 1142+ }143+ test ! -e "$staging/$name" || {144+ echo "duplicate preview artifact name: $name" >&2145+ exit 1146+ }147+ cp "$artifact" "$staging/$name"148+ sha256="$(digest "$staging/$name")"149+ printf '%s %s\n' "$sha256" "$name" >"$staging/$name.sha256"150+ test "$count" -eq 0 || entries="$entries,"151+ entries="$entries{\"name\":\"$name\",\"sha256\":\"$sha256\"}"152+ count=$((count + 1))153+ done < <(find "$bundle_root" -type f \154+ \( -name '*.dmg' -o -name '*.deb' -o -name '*.rpm' \155+ -o -name '*.AppImage' -o -name '*.msi' -o -name '*.exe' \) \156+ | sort)157+158+ test "$count" -gt 0 || {159+ echo "the bundler produced no recognised preview artifact" >&2160+ exit 1161+ }162+163+ cat >"$staging/preview-manifest.json" <<EOF164+ {165+ "artifacts": [$entries166+ ],167+ "format": 1,168+ "git_commit": "$(git rev-parse HEAD)",169+ "platform": "$PLATFORM",170+ "product": "portal-client-preview",171+ "published": false,172+ "release_tag": "$RELEASE_TAG",173+ "signed": false,174+ "target": "$TARGET",175+ "version": "$version"176+ }177+ EOF178+ echo "staging=$staging" >>"$GITHUB_OUTPUT"179+180+ - name: Attest preview build provenance181+ uses: actions/attest@f7c74d28b9d84cb8768d0b8ca14a4bac6ef463e6 # v4.1.0182+ with:183+ subject-path: ${{ steps.collect.outputs.staging }}/*184+185+ # Deliberately an Actions artifact, never a release asset. The published186+ # release contract stays exactly the macOS arm64 DMG set.187+ - name: Retain unpublished preview build188+ uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2189+ with:190+ name: portal-preview-${{ inputs.tag }}-${{ matrix.target }}191+ path: ${{ steps.collect.outputs.staging }}192+ if-no-files-found: error193+ retention-days: 14194+40195 publish:41- name: Sign, notarize, and publish macOS arm64196+ name: Build and publish ad-hoc macOS arm6442197 needs: policy43198 runs-on: macos-2644199 timeout-minutes: 90@@ -72,53 +227,16 @@ jobs:
72227 - name: Install pinned Tauri CLI73228 run: cargo install tauri-cli --version 2.11.3 --locked74229 75- - name: Import Developer ID certificate76- env:77- APPLE_CERTIFICATE_BASE64: ${{ secrets.PORTAL_APPLE_CERTIFICATE_BASE64 }}78- APPLE_CERTIFICATE_PASSWORD: ${{ secrets.PORTAL_APPLE_CERTIFICATE_PASSWORD }}79- run: |80- certificate_path="$RUNNER_TEMP/portal-developer-id.p12"81- keychain_path="$RUNNER_TEMP/portal-signing.keychain-db"82- keychain_password="$(openssl rand -hex 32)"83- echo "::add-mask::$keychain_password"84- python3 - "$certificate_path" <<'PY'85- import base6486- import os87- import pathlib88- import sys89-90- encoded = os.environ.get("APPLE_CERTIFICATE_BASE64", "")91- if not encoded:92- raise SystemExit("PORTAL_APPLE_CERTIFICATE_BASE64 is not configured")93- pathlib.Path(sys.argv[1]).write_bytes(base64.b64decode(encoded, validate=True))94- PY95- chmod 0600 "$certificate_path"96- security create-keychain -p "$keychain_password" "$keychain_path"97- security set-keychain-settings -lut 21600 "$keychain_path"98- security unlock-keychain -p "$keychain_password" "$keychain_path"99- security default-keychain -s "$keychain_path"100- security import "$certificate_path" \101- -k "$keychain_path" \102- -P "$APPLE_CERTIFICATE_PASSWORD" \103- -T /usr/bin/codesign \104- -T /usr/bin/security105- security set-key-partition-list \106- -S apple-tool:,apple: \107- -s \108- -k "$keychain_password" \109- "$keychain_path" >/dev/null110-111- - name: Build and verify signed release assets112- env:113- APPLE_SIGNING_IDENTITY: ${{ secrets.PORTAL_APPLE_SIGNING_IDENTITY }}114- APPLE_ID: ${{ secrets.PORTAL_APPLE_ID }}115- APPLE_PASSWORD: ${{ secrets.PORTAL_APPLE_PASSWORD }}116- APPLE_TEAM_ID: ${{ secrets.PORTAL_APPLE_TEAM_ID }}230+ # Portal has no Apple Developer ID. The packaging gate refuses to run in231+ # ad-hoc mode if any Apple credential is present in the environment, so232+ # this job deliberately provides none.233+ - name: Build and verify ad-hoc release assets117234 run: |118235 release_dir="$RUNNER_TEMP/portal-release"119236 mkdir -m 0700 "$release_dir"120237 ./scripts/package-macos-client.sh \121238 --release-tag "$RELEASE_TAG" \239+ --signing adhoc \122240 --output-dir "$release_dir"123241 124242 - name: Attest exact Portal build provenance@@ -186,16 +304,3 @@ jobs:
186304 --release-tag "$RELEASE_TAG" \187305 --names-json "$asset_names"188306 189- - name: Remove signing keychain190- if: always()191- run: |192- security delete-keychain "$RUNNER_TEMP/portal-signing.keychain-db" 2>/dev/null || true193- python3 - "$RUNNER_TEMP/portal-developer-id.p12" <<'PY'194- import pathlib195- import sys196-197- path = pathlib.Path(sys.argv[1])198- if path.exists():199- path.write_bytes(b"\0" * path.stat().st_size)200- path.unlink()201- PY@@ -206,15 +206,11 @@ jobs:
206206 --source-commit "${{ steps.source.outputs.commit }}" \207207 --public-key-file "$GITHUB_WORKSPACE/release/sigil-minisign.pub"208208 209- - name: Verify signed Portal assets against the same source tag210- env:211- PORTAL_APPLE_TEAM_ID: ${{ secrets.PORTAL_APPLE_TEAM_ID }}209+ - name: Verify Portal assets against the same source tag212210 run: |213211 portal_dir="$RUNNER_TEMP/portal-release"214212 install -d -m 0700 "$portal_dir"215213 gh release download "$RELEASE_TAG" --dir "$portal_dir" --pattern 'Portal-*'216- expected_team_id="$(<release/portal-apple-team-id.txt)"217- test "$PORTAL_APPLE_TEAM_ID" = "$expected_team_id"218214 python3 scripts/verify-portal-release.py assets \219215 --repo-dir "$GITHUB_WORKSPACE" \220216 --release-tag "$RELEASE_TAG" \@@ -230,13 +226,13 @@ jobs:
230226 ./scripts/verify-macos-portal-signature.sh \231227 --dmg "$portal_dir/Portal-${RELEASE_TAG#v}-arm64.dmg" \232228 --expected-version "${RELEASE_TAG#v}" \233- --expected-team-id "$expected_team_id"229+ --signing adhoc234230 235231 - name: Publish verified prerelease236232 run: |237233 gh release edit "$RELEASE_TAG" \238234 --draft=false \239235 --prerelease \240- --notes "Verified public alpha for $RELEASE_TAG: signed Linux glibc 2.17 x86_64 Sigil host package, validated on the supported Bazzite appliance, plus Developer ID signed, notarized, stapled macOS arm64 Portal. Hardware UAT remains tracked separately."236+ --notes "Verified public alpha for $RELEASE_TAG: Minisign-signed Linux glibc 2.17 x86_64 Sigil host package, validated on the supported Bazzite appliance, plus an ad-hoc signed macOS arm64 Portal DMG. Portal is NOT notarized: macOS will refuse it on first launch until you allow it under System Settings > Privacy & Security. Verify the DMG with its published SHA-256 and with 'gh attestation verify'. Hardware UAT remains tracked separately."241237 test "$(gh release view "$RELEASE_TAG" --json isDraft --jq '.isDraft')" = false242238 test "$(gh release view "$RELEASE_TAG" --json isPrerelease --jq '.isPrerelease')" = true@@ -115,15 +115,17 @@ Sigil host.
115115 `release/sigil-target-contract.txt`. That name describes the binary ABI, not116116 validated host support; the public bootstrap remains Bazzite-only until117117 another environment passes its own packaging and hardware gates.118-- Portal is a compiled desktop download, never a shell install. The first119- public target is macOS arm64 and requires Developer ID signing, hardened120- runtime, notarization, stapling, and strict Gatekeeper verification. Its121- TeamIdentifier must equal `release/portal-apple-team-id.txt`, and every122- Portal asset must carry protected exact-tag GitHub build provenance. Do not123- advertise an unavailable platform/architecture as a download.124-- The Minisign secret, Apple certificate, notarization credentials, host125- identity, and FIDO-derived secrets must never enter the repository, release126- archive, logs, command line, or general website deployment environment.118+- Portal is a compiled desktop download, never a shell install. The only119+ published target is macOS arm64. It is ad-hoc signed and deliberately NOT120+ Developer ID signed or notarized, so trust comes from exact-tag GitHub build121+ provenance plus the published SHA-256, and every Portal asset must carry that122+ provenance. The packaging gate refuses to build in ad-hoc mode if any123+ `APPLE_*` credential is present, and no published surface may claim124+ notarization or Gatekeeper verification. Do not advertise an unavailable125+ platform/architecture as a download.126+- The Minisign secret, host identity, and FIDO-derived secrets must never enter127+ the repository, release archive, logs, command line, or general website128+ deployment environment.127129 128130 ## Latency and correctness invariants129131 @@ -152,8 +154,9 @@ Sigil host.
152154 - Issue #4: configure the offline Minisign trust root, publish the signed Sigil153155 asset set, and prove clean install plus upgrade/rollback from the public154156 command.155-- Issue #5: configure the committed Apple TeamIdentifier pin, then publish the156- signed/notarized/attested macOS arm64 Portal DMG, digest, and manifest.157+- Issue #5: publish the ad-hoc signed, attested macOS arm64 Portal DMG, digest,158+ and manifest, with the un-notarized first-launch consequence stated plainly on159+ every user-facing surface.157160 - Issue #6: prove physically headless cold boot, physical client controller158161 gameplay, mouse buttons consumed by Gamescope/an actual game, sustained A/V159162 and resource percentiles without latency growth, difficult-NAT relay@@ -181,10 +181,16 @@ assets carry a single frozen build-target suffix, `linux-glibc2.17-x86_64`,
181181 pinned by `release/sigil-target-contract.txt`; that name describes the binary182182 ABI (glibc 2.17, x86-64), not a distribution, so one build runs across the183183 supported AMD hosts. Portal is a compiled, signed desktop application, never a184-shell install; the first public target is macOS arm64. The packaging, signing,185-and publication ceremonies are documented in184+shell install; the only published target is macOS arm64, which is ad-hoc signed185+rather than Apple-notarized, so macOS blocks its first launch until you allow it186+under System Settings, Privacy and Security. macOS x86_64,187+Linux x86_64, and Windows x86_64 are built and attested every release as188+unpublished preview artifacts. The packaging, signing, and publication189+ceremonies are documented in186190 [public release delivery](docs/public-release-delivery.md) and the187-[Portal release runbook](docs/portal-release.md).191+[Portal release runbook](docs/portal-release.md). The operator path from the192+committed `unconfigured` trust pins to a first published release is193+[release credential bring-up](docs/release-credentials.md).188194 189195 ## License190196 @@ -1,8 +1,34 @@
11 # Portal platform and relative-pointer support22 3-Portal's first supported download is macOS arm64. Its relative-pointer path4-uses CoreGraphics cursor disassociation and has live Mac-to-Gamescope hardware5-evidence. Linux, Windows, and other Portal builds are not release targets yet.3+Portal's only supported download is macOS arm64. Its relative-pointer path uses4+CoreGraphics cursor disassociation and has live Mac-to-Gamescope hardware5+evidence. No other target is published, advertised, or reachable from goq.sh.6+7+## Release tiers8+9+Building a target and supporting a target are separate decisions. Portal has10+three tiers, and a target only moves up a tier through a reviewed policy change11+backed by the acceptance evidence below.12+13+| Tier | Targets | What the project does |14+| --- | --- | --- |15+| Published | macOS arm64 | Ad-hoc signed, attested, attached to the GitHub Release with its SHA-256, and linked from goq.sh after operator verification. Not notarized: macOS blocks the first launch |16+| Preview | macOS x86_64, Linux x86_64, Windows x86_64 | Built unsigned from the exact release tag and attested on every release. Retained only as a 14-day Actions artifact. Never a release asset, never linked, never advertised |17+| Unsupported | Everything else, including arm64 Linux and arm64 Windows | Not built and not gated |18+19+Preview artifacts exist so the matrix cannot silently rot and so platform UAT20+has exact, provenance-bound bytes to test. They are not a download channel.21+Because they carry no signature, a preview artifact obtained from anywhere22+other than the tag's own workflow run must be treated as untrusted.23+24+A preview build is a default-feature build. It therefore has no relative25+pointer, per the build policy below, and is not a usable substitute for the26+macOS client for mouse-driven games.27+28+`jobs.preview` in `.github/workflows/portal-release.yml` holds only29+`contents: read`, so it is structurally incapable of adding an asset to the30+release. `.github/workflows/ci.yml` compile-checks the preview targets under31+both pointer policies on every pull request.632 733 ## Build policy834 @@ -24,8 +50,10 @@ cargo check --locked -p portal --all-targets \
2450 2551 The complete repository gate runs the default test suite and separately2652 compiles the experimental branch. On Linux CI this proves both non-macOS27-configurations compile. Portal's macOS release job continues to build without28-feature flags.53+configurations compile. The `portal-target-matrix` CI job repeats both54+compilations natively for macOS x86_64 and Windows x86_64, so a preview target55+cannot break without a pull request failing. Portal's macOS release job56+continues to build without feature flags.2957 3058 ## Non-macOS acceptance checklist3159 @@ -59,6 +87,13 @@ the evidence.
5987 6. Run `./scripts/verify-demo-build.sh` and retain the exact Portal-to-UMPC6088 session evidence required by `AGENTS.md`. A config check, unit test, or6189 compile-only result is not hardware acceptance.90+7. On Windows, close the enrollment-storage gap before proposing support.91+ `secure_config_dir` and `read_bounded_file` in92+ `src-tauri/src/commands/enrollment.rs` hard-gate their ownership check,93+ `0o700` tightening, and `O_NOFOLLOW` open behind `#[cfg(unix)]`. A Windows94+ build therefore stores the enrollment profile without an equivalent95+ owner-only ACL or symlink/junction refusal. Windows needs its own reviewed96+ implementation of both protections; compiling is not the same as being safe.6297 6398 Removing the feature gate requires reviewed evidence for every platform being6499 enabled and an explicit release-policy change. Do not infer support from Tao@@ -1,14 +1,29 @@
11 # Portal public release runbook (issue #5)22 33 This runbook completes the repository-controlled portion of4-[issue #5](https://github.com/FelineStateMachine/goq/issues/5). The first5-supported Portal download is **macOS arm64 only**. x86_64, universal, Linux,6-and Windows builds remain unavailable until they receive equivalent signing7-and release gates.4+[issue #5](https://github.com/FelineStateMachine/goq/issues/5). The published5+Portal download is **macOS arm64 only**.6+7+Issue #5 asks for an explicit, recorded decision between arm64, x86_64, and8+universal. The decision is **arm64-only, as separate-architecture builds rather9+than a universal binary**. A universal DMG would double the download for every10+user to serve a shrinking Intel population, and each additional published11+architecture adds a notarization, Gatekeeper, and promotion path that must be12+independently proven rather than assumed. macOS x86_64, Linux x86_64, and13+Windows x86_64 are instead built and attested every release as unpublished14+preview artifacts. Revisit this decision only with demand evidence, not by15+default. See [Portal platform support](portal-platform-support.md) for the tier16+definitions.817 918 Portal is always a compiled DMG download. There is no shell installer and no1019 fallback to an ad-hoc, demo, or development build.1120 21+Portal is distributed **unsigned by Apple standards**: ad-hoc signed, not22+notarized, not stapled, and not accepted by Gatekeeper without user action.23+See the signing boundary in24+[public release delivery](public-release-delivery.md) for why, and what25+replaces Apple as the trust anchor.26+1227 The release build uses no optional Cargo features. In particular,1328 `demo-direct-node` and `experimental-non-macos-pointer-capture` are forbidden1429 from the default feature set and from the packaging command. The latter exists@@ -16,37 +31,24 @@ only to compile the unverified Tao + browser Pointer Lock path during
1631 non-macOS platform UAT; see1732 [Portal platform support](portal-platform-support.md).1833 19-## One-time GitHub and Apple setup34+## One-time GitHub setup2035 2136 Protect the GitHub environment named `main` with required reviewers and limit22-deployment branches/tags according to the repository's release policy. Add23-these environment secrets:24-25-| Secret | Purpose |26-| --- | --- |27-| `PORTAL_APPLE_CERTIFICATE_BASE64` | Base64-encoded Developer ID Application PKCS#12 certificate |28-| `PORTAL_APPLE_CERTIFICATE_PASSWORD` | PKCS#12 import password |29-| `PORTAL_APPLE_SIGNING_IDENTITY` | Exact `Developer ID Application: ...` keychain identity |30-| `PORTAL_APPLE_ID` | Apple account used by the notarization service |31-| `PORTAL_APPLE_PASSWORD` | App-specific password for notarization |32-| `PORTAL_APPLE_TEAM_ID` | Apple Developer team identifier |33-34-Do not put certificate bytes, passwords, API keys, or secret-derived output in35-the repository, workflow artifacts, release notes, or website manifest. The36-workflow imports the certificate into an ephemeral keychain and removes it in37-an always-running cleanup step.38-39-The Apple team identifier itself is public release policy, not a credential.40-Before the first release tag, replace `unconfigured` in41-`release/portal-apple-team-id.txt` with the exact ten-character TeamIdentifier42-for the protected Developer ID certificate. The protected43-`PORTAL_APPLE_TEAM_ID` secret must match that committed pin. Packaging,44-promotion, and hardware UAT all fail closed if the pin is missing, malformed,45-or different from the application signature.37+deployment branches/tags according to the repository's release policy.38+39+The Portal publication job needs **no Apple secrets**, because Portal is not40+Developer ID signed or notarized. The packaging gate actively refuses to run in41+ad-hoc mode when any `APPLE_*` credential is present in the environment, so a42+leaked or half-configured credential cannot silently produce a differently43+signed artifact.44+45+The job does require `attestations: write` and `id-token: write`, since the46+GitHub/Sigstore build-provenance attestation is what replaces Apple as the47+trust anchor.4648 4749 ## Prepare an exact tag4850 49-Choose a SemVer tag such as `v0.3.0-alpha.1`. Before tagging, update every51+Choose a SemVer tag such as `v0.1.0`. Before tagging, update every5052 workspace package version and `src-tauri/tauri.conf.json` to the tag's version.5153 The shared verifier rejects a dirty worktree, a tag not resolving exactly to5254 `HEAD`, any version mismatch, a non-arm64 target, or a Portal manifest that@@ -76,12 +78,14 @@ The workflow performs this fail-closed sequence:
7678 1. Re-verifies clean tag, exact `HEAD`, workspace versions, Tauri version, and7779 demo-feature policy on Linux.7880 2. Uses a native `macos-26` arm64 runner and the protected `main` environment.79-3. Builds without feature flags and requires Developer ID Application signing,80- hardened runtime, notarization, app and DMG stapling, strict code-signature81- verification, and Gatekeeper acceptance.82-4. Confirms the executable contains only arm64, the Apple TeamIdentifier equals83- the committed signer pin, and the DMG, digest, and JSON manifest agree with84- the source tag and commit.81+3. Builds without feature flags in `--signing adhoc` mode, verifies the DMG,82+ and requires the mandatory arm64 ad-hoc signature. It rejects the build if a83+ certificate authority chain, an Apple TeamIdentifier, or a notarization84+ ticket is present, so an ad-hoc release cannot quietly become something else.85+4. Confirms the executable contains only arm64, the bundle identifier is86+ `sh.goq.portal`, no host or credential artifact is inside the bundle, and the87+ DMG, digest, and JSON manifest agree with the source tag and commit. The88+ manifest records `signing: "adhoc"` and `notarized: false`.8589 5. Creates GitHub/Sigstore build-provenance attestations for the exact three8690 Portal assets from the protected tag-ref workflow.8791 6. Requires the existing draft to contain exactly the two Sigil candidate@@ -94,26 +98,47 @@ The workflow performs this fail-closed sequence:
9498 7. Reads GitHub's remote asset list and requires the exact five-file combined9599 pre-signature set. It deliberately leaves the release as a draft.96100 101+## Unpublished preview matrix102+103+The same workflow runs a `preview` job for macOS x86_64, Linux x86_64, and104+Windows x86_64. It builds the exact release tag with no optional features,105+records a `preview-manifest.json` with each artifact's SHA-256, attests the106+build provenance, and uploads the result as the 14-day Actions artifact107+`portal-preview-$tag-$target`.108+109+The job holds `contents: read` only. It cannot attach an asset to the release,110+so the published contract stays exactly the three macOS arm64 files and the111+Sigil promotion gate still sees its expected six-asset set. Preview builds run112+with `fail-fast: false` and are not a dependency of `publish`; a Windows113+bundler failure must never block a signed macOS release.114+115+Preview artifacts are unsigned. Do not publish them, link them, or hand them to116+users as a download. Use them for the platform acceptance checklist in117+[Portal platform support](portal-platform-support.md), and only from the tag's118+own workflow run.119+97120 The operator next attaches the offline Sigil `.minisig`. The **Sigil Release**98121 `promote-signed-draft` operation requires exactly all six Portal and Sigil99122 assets, re-verifies the Sigil signature/archive/provenance and Portal100123 digest/manifest against the same tag, verifies every Portal asset's attestation101124 against the exact Portal workflow, source tag, and source commit, mounts the102-downloaded DMG on a native arm64 macOS runner, repeats the pinned Developer ID,103-Gatekeeper, and stapling checks, and only then publishes the prerelease with104-final notes.125+downloaded DMG on a native arm64 macOS runner, repeats the ad-hoc signature,126+architecture, identifier, and payload checks, confirms no notarization ticket127+appeared, and only then publishes the prerelease with final notes.105128 106129 If signing, upload, or remote verification fails, the shared release remains a107130 draft for diagnosis. Do not replace assets on an existing tag; fix the source,108131 delete an unpublished failed draft after review, and create a new version/tag109132 when provenance changed.110133 111-For a local Apple-credentialed rehearsal on an arm64 Mac, use an empty directory112-outside the repository:134+For a local rehearsal on an arm64 Mac, use an empty directory outside the135+repository. No Apple credentials are needed, and any that are set will be136+rejected:113137 114138 ```bash115139 scripts/package-macos-client.sh \116- --release-tag v0.3.0-alpha.1 \140+ --release-tag v0.1.0 \141+ --signing adhoc \117142 --output-dir /absolute/empty/release-directory118143 ```119144 @@ -131,19 +156,19 @@ contains no URL. After the prerelease is promoted:
131156 ```json132157 {133158 "architecture": "arm64",134- "asset": "Portal-0.3.0-alpha.1-arm64.dmg",159+ "asset": "Portal-0.1.0-arm64.dmg",135160 "available": true,136- "checksum_asset": "Portal-0.3.0-alpha.1-arm64.dmg.sha256",137- "checksum_url": "https://github.com/FelineStateMachine/goq/releases/download/v0.3.0-alpha.1/Portal-0.3.0-alpha.1-arm64.dmg.sha256",138- "download_url": "https://github.com/FelineStateMachine/goq/releases/download/v0.3.0-alpha.1/Portal-0.3.0-alpha.1-arm64.dmg",139- "manifest_asset": "Portal-0.3.0-alpha.1-arm64.json",140- "manifest_url": "https://github.com/FelineStateMachine/goq/releases/download/v0.3.0-alpha.1/Portal-0.3.0-alpha.1-arm64.json",161+ "checksum_asset": "Portal-0.1.0-arm64.dmg.sha256",162+ "checksum_url": "https://github.com/FelineStateMachine/goq/releases/download/v0.1.0/Portal-0.1.0-arm64.dmg.sha256",163+ "download_url": "https://github.com/FelineStateMachine/goq/releases/download/v0.1.0/Portal-0.1.0-arm64.dmg",164+ "manifest_asset": "Portal-0.1.0-arm64.json",165+ "manifest_url": "https://github.com/FelineStateMachine/goq/releases/download/v0.1.0/Portal-0.1.0-arm64.json",141166 "platform": "macos",142- "release_tag": "v0.3.0-alpha.1",143- "release_url": "https://github.com/FelineStateMachine/goq/releases/tag/v0.3.0-alpha.1",167+ "release_tag": "v0.1.0",168+ "release_url": "https://github.com/FelineStateMachine/goq/releases/tag/v0.1.0",144169 "sha256": "REPLACE_WITH_VERIFIED_LOWERCASE_SHA256",145- "verification": "developer-id+hardened-runtime+notarized+stapled+gatekeeper",146- "version": "0.3.0-alpha.1"170+ "verification": "adhoc-signed+github-attested+sha256",171+ "version": "0.1.0"147172 }148173 ```149174 @@ -57,15 +57,16 @@ Before creating the bundle the harness performs all of these fail-closed checks:
5757 - `gh attestation verify` validates all three Portal files against the exact5858 `FelineStateMachine/goq/.github/workflows/portal-release.yml` signer workflow,5959 `refs/tags/vVERSION`, tagged source commit, and GitHub-hosted runner boundary.60-- `scripts/verify-macos-portal-signature.sh` validates the DMG, Developer ID61- signature, hardened runtime, notarization ticket, staple, Gatekeeper result,62- bundle identity/version, arm64-only executable, containment boundary, and the63- exact Apple TeamIdentifier pinned in `release/portal-apple-team-id.txt`.60+- `scripts/verify-macos-portal-signature.sh --signing adhoc` validates the DMG,61+ the mandatory arm64 ad-hoc signature, bundle identity/version, arm64-only62+ executable, and containment boundary. It also proves the negative: no63+ certificate authority chain, no Apple TeamIdentifier, and no notarization64+ ticket. Portal is not notarized, so there is no Gatekeeper result to record.6465 6566 If the reviewed Sigil key is still the `unconfigured` sentinel, Minisign or66-macOS verification tools are unavailable, the Apple TeamIdentifier pin is67-invalid, the GitHub API or attestations cannot be verified, the macOS verifier68-is absent, or any verification cannot run, initialization fails. There is no67+macOS verification tools are unavailable, the GitHub API or attestations cannot68+be verified, the macOS verifier is absent, or any verification cannot run,69+initialization fails. There is no6970 candidate, offline, or test bypass in the UAT command.7071 7172 The bundle format is intentionally append-only at the command boundary. To@@ -31,6 +31,12 @@ Portal preserves the package builder's versioned macOS contract:
3131 Do not advertise x86_64, universal, or Linux Portal downloads until those exact3232 artifacts pass an equivalent signed packaging gate.3333 34+macOS x86_64, Linux x86_64, and Windows x86_64 are built and attested on every35+release as unpublished preview artifacts, retained only as short-lived Actions36+artifacts. They are unsigned, are not release assets, and are not a download37+channel. The published Portal contract remains exactly the three macOS arm6438+files above.39+3440 ## Sigil bootstrap trust boundary3541 3642 `website/install-sigil` is the public bootstrap source. Before the release@@ -100,8 +106,8 @@ scripts/sign-bazzite-release.sh \
100106 ```101107 102108 Before promotion, run the **Portal release** workflow for the same tag. It103-requires the untouched two-asset Sigil draft, attaches exactly the signed and104-notarized Portal DMG, checksum, and manifest, verifies the five-asset combined109+requires the untouched two-asset Sigil draft, attaches exactly the ad-hoc110+signed Portal DMG, checksum, and manifest, verifies the five-asset combined105111 draft, and leaves it unpublished. Transfer only the resulting Sigil `.minisig`106112 back to the online release operator and attach it to that draft. Do not use107113 `--clobber` for any asset.@@ -111,8 +117,8 @@ six-asset public-alpha set, verifies the detached Sigil signature with the
111117 committed public key, checks the outer digest, inspects the archive without112118 extraction, binds both products to the same tag and commit, re-verifies the113119 Portal digest and release manifest, then mounts the downloaded DMG on a native114-arm64 macOS runner and repeats Developer ID, hardened-runtime, Gatekeeper,115-stapling, architecture, identifier, and payload checks. Only then does it120+arm64 macOS runner and repeats the ad-hoc signature, architecture, identifier,121+and payload checks, including proof that no notarization ticket is present. Only then does it116122 replace the incomplete draft notes and publish the prerelease. The workflow has117123 no input, environment, or secret capable of118124 receiving the offline secret key.@@ -152,18 +158,38 @@ distinct signed candidates. Record, without node IDs or credentials:
152158 153159 ## Portal signing boundary154160 155-The existing macOS package gate requires a Developer ID Application identity,156-hardened runtime, notarization, stapling, strict Gatekeeper assessment, and a157-release build without `demo-direct-node`. Store the certificate and158-notarization credentials only in the protected GitHub `main` environment with159-required reviewers. The release workflow uses those secrets only in its macOS160-arm64 publication job.161-162-Pin the public Apple TeamIdentifier in `release/portal-apple-team-id.txt` before163-the first tag. The protected secret, produced app signature, promotion verifier,164-and UAT verifier must all equal that committed value. The Portal tag-ref build165-also emits GitHub artifact attestations; promotion requires each of the three166-Portal assets to verify against the exact workflow, tag ref, and source commit.161+Portal is **not** signed with an Apple Developer ID and is **not** notarized.162+The project has no Apple Developer Program membership, and there is no free163+path to notarization. This is a deliberate, recorded decision rather than an164+unfinished step.165+166+The DMG carries only the ad-hoc signature that arm64 macOS requires in order to167+execute a binary at all. An ad-hoc signature asserts nothing about origin, so168+the packaging gate additionally proves that no certificate authority chain, no169+Apple TeamIdentifier, and no notarization ticket crept into a build that claims170+to be ad-hoc.171+172+Trust therefore comes from provenance, not from Apple:173+174+- a GitHub/Sigstore build-provenance attestation bound to the exact workflow,175+ tag ref, and source commit;176+- the published SHA-256 beside the DMG;177+- the release manifest, which records `signing: "adhoc"` and `notarized: false`178+ rather than claiming verification the artifact does not have.179+180+Promotion requires each of the three Portal assets to verify against the exact181+workflow, tag ref, and source commit. Users are expected to check the digest182+and, ideally, `gh attestation verify`.183+184+The consequence for users is real and must never be hidden: macOS blocks the185+first launch, and the user has to allow Portal under System Settings, Privacy186+and Security. The website says so, and the release notes say so.187+188+`scripts/package-macos-client.sh --signing developer-id` still implements the189+full Developer ID, notarization, stapling, and Gatekeeper gate for the day a190+membership exists. It is not exercised by CI and must be re-validated end to191+end before it is trusted; enabling it also means restoring the192+`release/portal-apple-team-id.txt` pin.167193 168194 The first release automation must verify that the git tag, every Cargo package169195 version, Tauri version, artifact filenames, and manifests agree before it@@ -0,0 +1,195 @@
1+# Release credential bring-up2+3+Everything needed to publish goq is already built and already fails closed. The4+only thing standing between this repository and a public release is that two5+committed trust pins still read `unconfigured`:6+7+| Pin | File | Becomes |8+| --- | --- | --- |9+| Sigil publisher key | `release/sigil-minisign.pub` | The offline Minisign public key |10+| Sigil bootstrap channel | `website/install-sigil` | `publisher_key` and `release_tag` |11+12+Both are free. There is no third credential, because Portal is deliberately not13+signed with an Apple Developer ID.14+15+This document is the operator path from those sentinels to a published16+prerelease. It closes the credential half of17+[issue #4](https://github.com/FelineStateMachine/goq/issues/4) and18+[issue #5](https://github.com/FelineStateMachine/goq/issues/5).19+20+Read [Public release delivery](public-release-delivery.md) for the trust model21+and [the Portal runbook](portal-release.md) for the publication transaction.22+23+## Read this ordering constraint first24+25+`scripts/verify-sigil-bootstrap.py` accepts exactly two states: fully closed, or26+fully open. A commit that configures `release/sigil-minisign.pub` while27+`website/install-sigil` still says `unconfigured` is rejected as "partially28+configured", and the website gate fails.29+30+The bootstrap also pins one immutable `release_tag`. Combined with the31+promotion workflow reading `release/sigil-minisign.pub` from the checked-out32+tag, this means the release commit must already contain the real key and a33+bootstrap pinned to the tag it is itself becoming.34+35+Pushing either path to `main` triggers a goq.sh deployment. So the release36+commit is tagged and released **before** it is merged, or the public install37+command goes live pointing at a release that does not exist yet. The sequence38+in "First release" below does this in the correct order. Do not shortcut it.39+40+## Part A: the Sigil offline publisher key41+42+This key is the root of trust for every Bazzite install. It must be generated43+and must stay on a machine that is not the release runner and never a CI44+environment.45+46+```bash47+minisign -G -p sigil-minisign.pub -s sigil-release.key48+```49+50+Choose a strong passphrase and store it separately from the key file. Back up51+`sigil-release.key` offline, in two physically separate locations. There is no52+recovery path: losing it means every future release needs a new key and a53+reviewed re-pin, and disclosing it means an attacker can sign a package that54+the bootstrap will install without complaint.55+56+Prove the passphrase works before you depend on it:57+58+```bash59+echo test > /tmp/sigcheck60+minisign -Sm /tmp/sigcheck -s sigil-release.key61+minisign -Vm /tmp/sigcheck -P "$(tail -1 sigil-minisign.pub)"62+```63+64+`sigil-minisign.pub` is two lines, an `untrusted comment:` line and the base6465+key. Both matter, in different places:66+67+- `release/sigil-minisign.pub` takes the file verbatim. The verifier tolerates68+ the comment line and reads the key from the last non-empty line.69+- `website/install-sigil` takes **only the base64 key** in70+ `readonly publisher_key="..."`, with no comment line.71+72+The two must match exactly or the gate refuses to open the channel. The key73+decodes to exactly 42 bytes beginning `Ed`; anything else is rejected as74+malformed.75+76+Record the key fingerprint and publish it through a channel independent of the77+website, so a compromised site cannot also redefine what "the real key" is.78+79+## Part B: no Apple credentials80+81+Portal ships as an ad-hoc signed, un-notarized DMG. There is no Apple Developer82+Program membership, no Developer ID certificate, and no notarization83+credential, and none of them are required to cut a release.84+85+The packaging gate **refuses** to build in ad-hoc mode if any of86+`APPLE_SIGNING_IDENTITY`, `APPLE_CERTIFICATE`, `APPLE_API_KEY`,87+`APPLE_API_ISSUER`, `APPLE_API_KEY_PATH`, `APPLE_ID`, or `APPLE_PASSWORD` is88+set. Do not add them to the `main` environment "just in case".89+90+What replaces Apple as the trust anchor:91+92+- a GitHub/Sigstore build-provenance attestation bound to the exact workflow,93+ tag ref, and source commit;94+- the published SHA-256 beside the DMG;95+- a release manifest that records `signing: "adhoc"` and `notarized: false`.96+97+The cost is real and is stated on the website and in the release notes: macOS98+blocks Portal's first launch, and the user must allow it under System Settings,99+Privacy and Security.100+101+If a membership is ever purchased, `--signing developer-id` still implements the102+full Developer ID path, and `docs/public-release-delivery.md` records what has103+to be restored. That branch is not exercised by CI; re-validate it end to end104+before trusting it.105+106+## Part C: the protected GitHub environment107+108+Create an environment named `main` with required reviewers and restrict its109+deployment branches and tags to the release policy.110+111+It needs **no Apple secrets**. The website deployment needs112+`CLOUDFLARE_API_TOKEN` and `CLOUDFLARE_ACCOUNT_ID`; scope the token to Workers113+deployment on the goq.sh account only.114+115+No Minisign secret belongs here, or in any other GitHub environment. The116+promotion workflow has no input capable of receiving it, and that is deliberate.117+118+## First release, in order119+120+The tag is `v0.1.0`, matching the version already in every workspace manifest.121+122+1. **Prepare one reviewed release commit** on a branch. It replaces123+ `release/sigil-minisign.pub` with the real public key file and opens124+ `website/install-sigil` with the base64 `publisher_key` and125+ `readonly release_tag="v0.1.0"`. No version bump is needed.126+127+ Verify locally before pushing:128+129+ ```bash130+ python3 scripts/verify-sigil-bootstrap.py \131+ --bootstrap website/install-sigil \132+ --public-key-file release/sigil-minisign.pub133+ ./scripts/verify-website.sh134+ ./scripts/tests/portal-release.sh135+ ```136+137+ Expect `sigil_bootstrap_channel=open`.138+139+2. **Tag that exact commit and push the tag. Do not merge to `main` yet.**140+ Merging now would deploy an install command pointing at a release that does141+ not exist.142+143+3. **Run Sigil Release with `build-candidate`** against the tag. It creates the144+ draft and attaches the unsigned candidate archive and its checksum.145+146+4. **Sign offline.** Move the candidate bytes to the publisher machine,147+ independently confirm the tag commit, then:148+149+ ```bash150+ scripts/sign-bazzite-release.sh \151+ --tag v0.1.0 \152+ --archive /absolute/offline/path/sigil-v0.1.0-linux-glibc2.17-x86_64.tar.gz \153+ --source-commit "$tag_commit" \154+ --minisign-key /absolute/offline/path/sigil-release.key \155+ --public-key-file /absolute/offline/path/sigil-minisign.pub156+ ```157+158+ Carry only the resulting `.minisig` back and attach it to the draft. Never159+ use `--clobber`.160+161+5. **Run Portal release** against the same tag. It builds, verifies, and162+ attests the ad-hoc macOS arm64 assets, and separately builds and attests the163+ unpublished preview matrix. The draft stays a draft.164+165+6. **Run Sigil Release with `promote-signed-draft`.** It requires the exact166+ six-asset set, re-verifies both products against the same tag and commit,167+ and publishes the prerelease.168+169+7. **Verify as a stranger would**, on a different machine: download the DMG,170+ check the digest, run `gh attestation verify`, confirm the Gatekeeper prompt171+ behaves as documented, and run the public install command on clean Bazzite.172+173+8. **Merge the release commit to `main`.** This deploys goq.sh with the install174+ command open against the now-published tag.175+176+9. **Enable the Portal download** with the follow-up reviewed change to177+ `website/portal-release.json` described in178+ [the Portal runbook](portal-release.md). It needs the published DMG's179+ verified SHA-256, so it can only happen after step 6.180+181+Every subsequent release repeats steps 1 to 9, except that the key is already182+correct and only `release_tag` in `website/install-sigil` moves.183+184+## Deliberately not automated185+186+- The Minisign secret key never enters CI. Signing is a manual offline step.187+- Opening or moving the public install channel is a reviewed website commit.188+- Enabling the Portal download link is a separate reviewed commit made only189+ after an operator independently verifies the published artifact.190+- Preview matrix artifacts are never published. See191+ [Portal platform support](portal-platform-support.md).192+193+If a step fails, the release stays a draft. Fix the source, discard the194+unpublished draft after review, and use a new version and tag; do not replace195+assets on a tag that anyone may already have fetched.@@ -1 +0,0 @@
1-unconfigured@@ -6,21 +6,32 @@ umask 077
66 usage() {77 cat <<'EOF'88 Usage: scripts/package-macos-client.sh --release-tag vVERSION9- --output-dir /absolute/path9+ --output-dir /absolute/path --signing adhoc|developer-id1010 11-Build and verify a distributable macOS DMG. This gate requires:11+Build and verify a distributable macOS DMG. Both modes require:1212 1313 - a clean worktree;1414 - a release tag resolving exactly to HEAD and matching every package version;1515 - an Apple Silicon runner and the aarch64-apple-darwin Rust target;16- - APPLE_SIGNING_IDENTITY naming a Developer ID Application identity;17- - Tauri notarization credentials (App Store Connect API or Apple ID);18- - strict code-signing, hardened runtime, Gatekeeper, and stapling checks.16+ - an arm64-only executable, the pinned bundle identifier, a verified DMG,17+ and no host or credential artifact inside the client bundle.18+19+--signing adhoc is the published release mode. Portal has no Apple Developer20+ID, so the DMG is not notarized and Gatekeeper will not accept it silently.21+arm64 macOS refuses to execute a wholly unsigned binary, so this mode requires22+the linker's ad-hoc signature to be present and rejects any Developer ID23+signature that appeared unintentionally. Trust is anchored in GitHub/Sigstore24+build provenance and the published SHA-256, not in Apple.25+26+--signing developer-id additionally requires APPLE_SIGNING_IDENTITY, the27+committed team-identifier pin, notarization credentials, hardened runtime,28+stapling, and Gatekeeper acceptance. It is preserved for the day an Apple29+Developer Program membership exists and is NOT exercised by CI, so re-validate30+it end to end before trusting it.1931 2032 The normal release is built without optional Cargo features. Development2133 features, including demo-direct-node and22-experimental-non-macos-pointer-capture, and ad-hoc signatures are23-intentionally not accepted by this command.34+experimental-non-macos-pointer-capture, are intentionally not accepted.2435 EOF2536 }2637 @@ -33,9 +44,15 @@ script_dir="$(CDPATH='' cd -- "$(dirname -- "$0")" && pwd -P)"
3344 repo_dir="$(CDPATH='' cd -- "$script_dir/.." && pwd -P)"3445 output_dir=""3546 release_tag=""47+signing_mode=""3648 3749 while [[ $# -gt 0 ]]; do3850 case "$1" in51+ --signing)52+ [[ $# -ge 2 ]] || die "--signing requires a mode"53+ signing_mode="$2"54+ shift 255+ ;;3956 --output-dir)4057 [[ $# -ge 2 ]] || die "--output-dir requires a path"4158 output_dir="$2"@@ -58,6 +75,11 @@ done
5875 [[ "$(uname -m)" == arm64 ]] || die "the first public Portal release is macOS arm64 only"5976 [[ -n "$output_dir" && "$output_dir" == /* ]] || die "--output-dir must be absolute"6077 [[ -n "$release_tag" ]] || die "--release-tag is required"78+case "$signing_mode" in79+ adhoc|developer-id) ;;80+ "") die "--signing adhoc|developer-id is required; there is no default" ;;81+ *) die "unknown signing mode: $signing_mode" ;;82+esac6183 [[ -d "$output_dir" ]] || die "output directory does not exist"6284 output_dir="$(CDPATH='' cd -- "$output_dir" && pwd -P)"6385 case "$output_dir/" in@@ -77,32 +99,43 @@ python3 "$verifier" source --repo-dir "$repo_dir" --release-tag "$release_tag" >
7799 rustup target list --installed | grep -Fxq aarch64-apple-darwin \78100 || die "the aarch64-apple-darwin Rust target is required"79101 80-signing_identity="${APPLE_SIGNING_IDENTITY:-}"81-[[ "$signing_identity" == Developer\ ID\ Application:* ]] \82- || die "APPLE_SIGNING_IDENTITY must name a Developer ID Application identity"83-security find-identity -v -p codesigning | grep -Fq "\"$signing_identity\"" \84- || die "APPLE_SIGNING_IDENTITY is not available in the keychain"85-expected_team_id_file="$repo_dir/release/portal-apple-team-id.txt"86-[[ -f "$expected_team_id_file" && ! -L "$expected_team_id_file" ]] \87- || die "committed Portal Apple team identifier is missing or unsafe"88-expected_team_id="$(<"$expected_team_id_file")"89-[[ "$expected_team_id" =~ ^[A-Z0-9]{10}$ ]] \90- || die "release/portal-apple-team-id.txt must contain one Apple TeamIdentifier"91-[[ "${APPLE_TEAM_ID:-}" == "$expected_team_id" ]] \92- || die "APPLE_TEAM_ID does not match the committed Portal signer"93-94-api_credentials=false95-apple_id_credentials=false96-if [[ -n "${APPLE_API_ISSUER:-}" && -n "${APPLE_API_KEY:-}" && -n "${APPLE_API_KEY_PATH:-}" ]]; then97- [[ "$APPLE_API_KEY_PATH" == /* && -f "$APPLE_API_KEY_PATH" ]] \98- || die "APPLE_API_KEY_PATH must name an absolute private-key file"99- api_credentials=true102+signing_identity=""103+if [[ "$signing_mode" == developer-id ]]; then104+ signing_identity="${APPLE_SIGNING_IDENTITY:-}"105+ [[ "$signing_identity" == Developer\ ID\ Application:* ]] \106+ || die "APPLE_SIGNING_IDENTITY must name a Developer ID Application identity"107+ security find-identity -v -p codesigning | grep -Fq "\"$signing_identity\"" \108+ || die "APPLE_SIGNING_IDENTITY is not available in the keychain"109+ expected_team_id_file="$repo_dir/release/portal-apple-team-id.txt"110+ [[ -f "$expected_team_id_file" && ! -L "$expected_team_id_file" ]] \111+ || die "committed Portal Apple team identifier is missing or unsafe"112+ expected_team_id="$(<"$expected_team_id_file")"113+ [[ "$expected_team_id" =~ ^[A-Z0-9]{10}$ ]] \114+ || die "release/portal-apple-team-id.txt must contain one Apple TeamIdentifier"115+ [[ "${APPLE_TEAM_ID:-}" == "$expected_team_id" ]] \116+ || die "APPLE_TEAM_ID does not match the committed Portal signer"117+118+ api_credentials=false119+ apple_id_credentials=false120+ if [[ -n "${APPLE_API_ISSUER:-}" && -n "${APPLE_API_KEY:-}" && -n "${APPLE_API_KEY_PATH:-}" ]]; then121+ [[ "$APPLE_API_KEY_PATH" == /* && -f "$APPLE_API_KEY_PATH" ]] \122+ || die "APPLE_API_KEY_PATH must name an absolute private-key file"123+ api_credentials=true124+ fi125+ if [[ -n "${APPLE_ID:-}" && -n "${APPLE_PASSWORD:-}" && -n "${APPLE_TEAM_ID:-}" ]]; then126+ apple_id_credentials=true127+ fi128+ $api_credentials || $apple_id_credentials \129+ || die "set complete App Store Connect API or Apple ID notarization credentials"130+else131+ # Refuse to silently produce a half-signed artifact: in ad-hoc mode Tauri132+ # must not find any Apple credential in the environment.133+ for leaked in APPLE_SIGNING_IDENTITY APPLE_CERTIFICATE APPLE_API_KEY \134+ APPLE_API_ISSUER APPLE_API_KEY_PATH APPLE_ID APPLE_PASSWORD; do135+ [[ -z "${!leaked:-}" ]] \136+ || die "$leaked is set but --signing adhoc was requested"137+ done100138 fi101-if [[ -n "${APPLE_ID:-}" && -n "${APPLE_PASSWORD:-}" && -n "${APPLE_TEAM_ID:-}" ]]; then102- apple_id_credentials=true103-fi104-$api_credentials || $apple_id_credentials \105- || die "set complete App Store Connect API or Apple ID notarization credentials"106139 107140 # shellcheck source=/dev/null108141 source "$HOME/.cargo/env"@@ -121,17 +154,33 @@ dmg_path="$(find "$bundle_root/dmg" -maxdepth 1 -type f -name '*.dmg' -print)"
121154 hdiutil verify "$dmg_path"122155 codesign --verify --deep --strict --verbose=2 "$app_path"123156 signature_details="$(codesign -d --verbose=4 "$app_path" 2>&1)"124-grep -Fq "Authority=$signing_identity" <<<"$signature_details" \125- || die "application is not signed by the configured Developer ID identity"126-team_identifier="$(sed -n 's/^TeamIdentifier=//p' <<<"$signature_details")"127-[[ "$team_identifier" == "$expected_team_id" ]] \128- || die "application TeamIdentifier does not match the committed Portal signer"129-grep -Eq '^flags=.*runtime' <<<"$signature_details" \130- || die "application signature does not enable hardened runtime"131-spctl --assess --type execute --verbose=4 "$app_path"132-xcrun stapler validate "$app_path"133-xcrun stapler validate "$dmg_path"134-spctl --assess --type open --context context:primary-signature --verbose=4 "$dmg_path"157+if [[ "$signing_mode" == developer-id ]]; then158+ grep -Fq "Authority=$signing_identity" <<<"$signature_details" \159+ || die "application is not signed by the configured Developer ID identity"160+ team_identifier="$(sed -n 's/^TeamIdentifier=//p' <<<"$signature_details")"161+ [[ "$team_identifier" == "$expected_team_id" ]] \162+ || die "application TeamIdentifier does not match the committed Portal signer"163+ grep -Eq '^flags=.*runtime' <<<"$signature_details" \164+ || die "application signature does not enable hardened runtime"165+ spctl --assess --type execute --verbose=4 "$app_path"166+ xcrun stapler validate "$app_path"167+ xcrun stapler validate "$dmg_path"168+ spctl --assess --type open --context context:primary-signature --verbose=4 "$dmg_path"169+else170+ # arm64 macOS will not execute an unsigned binary at all, so the ad-hoc171+ # signature is a functional requirement rather than a security claim.172+ grep -Fq 'Signature=adhoc' <<<"$signature_details" \173+ || die "ad-hoc release must carry the mandatory arm64 ad-hoc signature"174+ if grep -q '^Authority=' <<<"$signature_details"; then175+ die "ad-hoc release unexpectedly carries a certificate authority chain"176+ fi177+ if grep -q '^TeamIdentifier=[A-Z0-9]' <<<"$signature_details"; then178+ die "ad-hoc release unexpectedly carries an Apple TeamIdentifier"179+ fi180+ if xcrun stapler validate "$dmg_path" >/dev/null 2>&1; then181+ die "ad-hoc release unexpectedly carries a notarization ticket"182+ fi183+fi135184 136185 executable="$app_path/Contents/MacOS/portal"137186 [[ -x "$executable" ]] || die "client executable is missing"@@ -156,12 +205,13 @@ manifest_path="$output_dir/Portal-${version}-arm64.json"
156205 install -m 0644 "$dmg_path" "$artifact_path"157206 artifact_sha256="$(shasum -a 256 "$artifact_path" | awk '{print $1}')"158207 printf '%s %s\n' "$artifact_sha256" "$artifact_name" >"$artifact_path.sha256"159-python3 - "$manifest_path" "$release_tag" "$version" "$git_commit" "$artifact_name" "$artifact_sha256" <<'PY'208+python3 - "$manifest_path" "$release_tag" "$version" "$git_commit" "$artifact_name" "$artifact_sha256" "$signing_mode" <<'PY'160209 import json161210 import pathlib162211 import sys163212 164-path, release_tag, version, git_commit, asset, digest = sys.argv[1:]213+path, release_tag, version, git_commit, asset, digest, signing = sys.argv[1:]214+developer_id = signing == "developer-id"165215 manifest = {166216 "architecture": "arm64",167217 "architectures": ["arm64"],@@ -169,17 +219,18 @@ manifest = {
169219 "bundle_identifier": "sh.goq.portal",170220 "checksum_asset": f"{asset}.sha256",171221 "demo_direct_node": False,172- "developer_id_signed": True,173- "format": 1,174- "gatekeeper_verified": True,222+ "developer_id_signed": developer_id,223+ "format": 2,224+ "gatekeeper_verified": developer_id,175225 "git_commit": git_commit,176- "hardened_runtime": True,177- "notarized": True,226+ "hardened_runtime": developer_id,227+ "notarized": developer_id,178228 "platform": "macos",179229 "product": "portal-client",180230 "release_tag": release_tag,181231 "sha256": digest,182- "stapled": True,232+ "signing": signing,233+ "stapled": developer_id,183234 "version": version,184235 }185236 pathlib.Path(path).write_text(json.dumps(manifest, indent=2, sort_keys=True) + "\n")@@ -195,5 +246,10 @@ printf 'client_package_sha256=%s\n' "$artifact_sha256"
195246 printf 'client_architectures=%s\n' "$architectures"196247 printf 'client_release_tag=%s\n' "$release_tag"197248 printf 'client_git_commit=%s\n' "$git_commit"198-printf 'client_signing=developer-id-hardened-runtime\n'199-printf 'client_notarization=stapled-and-validated\n'249+if [[ "$signing_mode" == developer-id ]]; then250+ printf 'client_signing=developer-id-hardened-runtime\n'251+ printf 'client_notarization=stapled-and-validated\n'252+else253+ printf 'client_signing=adhoc\n'254+ printf 'client_notarization=none\n'255+fi@@ -17,7 +17,6 @@ sigil_release_verifier="$script_dir/verify-sigil-release.sh"
1717 portal_release_verifier="$script_dir/verify-portal-release.py"1818 portal_signature_verifier="$script_dir/verify-macos-portal-signature.sh"1919 sigil_public_key="$repo_dir/release/sigil-minisign.pub"20-portal_apple_team_id_file="$repo_dir/release/portal-apple-team-id.txt"2120 2221 usage() {2322 cat <<'EOF'@@ -30,7 +29,7 @@ The harness never runs hardware tests or invents attestations. `record` ingests
3029 strict key=value evidence and `verify` succeeds only when every required gate is3130 present, current, bound to an exact signed release tag and verified release3231 assets, and internally valid. Hardware UAT initialization and verification must33-run on macOS so Portal signature, notarization, and Gatekeeper checks can run.32+run on macOS so the Portal ad-hoc signature and bundle checks can run.3433 EOF3534 }3635 @@ -271,7 +270,6 @@ verify_release_inputs() {
271270 local head272271 local worktree_status273272 local expected_names274- local portal_apple_team_id275273 276274 require_release_tag "$release_tag"277275 require_absolute_path "$archive"@@ -286,7 +284,6 @@ verify_release_inputs() {
286284 require_safe_regular_file "$portal_release_verifier"287285 require_safe_regular_file "$portal_signature_verifier"288286 require_safe_regular_file "$sigil_public_key"289- require_safe_regular_file "$portal_apple_team_id_file"290287 291288 verified_commit="$(git -C "$repo_dir" rev-parse --verify "refs/tags/$release_tag^{commit}" 2>/dev/null)" \292289 || die "release tag does not resolve through refs/tags/$release_tag"@@ -346,18 +343,12 @@ PY
346343 --asset-dir "$portal_assets" > /dev/null \347344 || die "Portal release asset verification failed"348345 349- portal_apple_team_id="$(awk 'NF { count += 1; value = $0 } END { if (count != 1) exit 1; print value }' \350- "$portal_apple_team_id_file")" \351- || die "committed Portal Apple TeamIdentifier pin must contain exactly one non-empty line"352- [[ "$portal_apple_team_id" =~ ^[A-Z0-9]{10}$ ]] \353- || die "committed Portal Apple TeamIdentifier pin is invalid"354-355346 [[ "$(uname -s)" == Darwin ]] || die "hardware UAT release verification must run on macOS"356347 bash "$portal_signature_verifier" \357348 --dmg "$verified_portal_dmg" \358349 --expected-version "$version" \359- --expected-team-id "$portal_apple_team_id" > /dev/null \360- || die "Portal macOS signature verification failed"350+ --signing adhoc > /dev/null \351+ || die "Portal macOS ad-hoc release verification failed"361352 362353 verify_published_release \363354 "$release_tag" \@@ -64,9 +64,7 @@ cp "$production_harness" "$fixture_repo/scripts/public-alpha-uat.sh"
6464 chmod 755 "$fixture_repo/scripts/public-alpha-uat.sh"6565 printf '%s\n' 'untrusted comment: fixture key' 'RWRmaXh0dXJlUHVibGljS2V5MDEyMzQ1Njc4OTAxMjM0NTY3ODkwMTI=' \6666 > "$fixture_repo/release/sigil-minisign.pub"67-printf '%s\n' 'ABC123DEF4' > "$fixture_repo/release/portal-apple-team-id.txt"6867 chmod 644 "$fixture_repo/release/sigil-minisign.pub"69-chmod 644 "$fixture_repo/release/portal-apple-team-id.txt"7068 7169 # The single-quoted fixture lines intentionally defer expansion to the generated verifier.7270 # shellcheck disable=SC2016@@ -117,7 +115,7 @@ printf '%s\n' \
117115 '[[ "$1" == --dmg && "$3" == --expected-version && "$4" == 1.2.3 ]]' \118116 '[[ "$(<"$2")" == "verified portal dmg fixture" ]]' \119117 'if [[ -n "${UAT_SIGNATURE_MARKER:-}" ]]; then printf invoked > "$UAT_SIGNATURE_MARKER"; fi' \120- '[[ "$5" == --expected-team-id && "$6" == ABC123DEF4 ]]' \118+ '[[ "$5" == --signing && "$6" == adhoc ]]' \121119 'printf "portal_signature_verification=ok\\n"' \122120 > "$fixture_repo/scripts/verify-macos-portal-signature.sh"123121 chmod 755 "$fixture_repo/scripts/verify-macos-portal-signature.sh"@@ -112,7 +112,7 @@ class PortalReleaseVerifierTests(unittest.TestCase):
112112 assets.mkdir()113113 dmg_name, checksum_name, manifest_name = VERIFIER.expected_asset_names("v0.1.0")114114 dmg = assets / dmg_name115- dmg.write_bytes(b"signed-notarized-dmg")115+ dmg.write_bytes(b"adhoc-signed-dmg")116116 digest = hashlib.sha256(dmg.read_bytes()).hexdigest()117117 (assets / checksum_name).write_text(f"{digest} {dmg_name}\n", encoding="utf-8")118118 manifest = VERIFIER.expected_release_manifest("v0.1.0", "a" * 40, digest)@@ -125,7 +125,13 @@ class PortalReleaseVerifierTests(unittest.TestCase):
125125 assets = root / "assets"126126 self.write_assets(assets)127127 result = VERIFIER.validate_assets(repo, "v0.1.0", assets, FakeGit())128- self.assertTrue(result["gatekeeper_verified"])128+ self.assertFalse(result["gatekeeper_verified"])129+ self.assertFalse(result["notarized"])130+ self.assertFalse(result["developer_id_signed"])131+ self.assertFalse(result["stapled"])132+ self.assertFalse(result["hardened_runtime"])133+ self.assertEqual(result["signing"], "adhoc")134+ self.assertEqual(result["format"], 2)129135 _, checksum_name, manifest_name = VERIFIER.expected_asset_names("v0.1.0")130136 original_checksum = (assets / checksum_name).read_text(encoding="utf-8")131137 (assets / checksum_name).write_text("0" * 64 + " wrong.dmg\n", encoding="utf-8")@@ -133,7 +139,7 @@ class PortalReleaseVerifierTests(unittest.TestCase):
133139 VERIFIER.validate_assets(repo, "v0.1.0", assets, FakeGit())134140 (assets / checksum_name).write_text(original_checksum, encoding="utf-8")135141 manifest = json.loads((assets / manifest_name).read_text(encoding="utf-8"))136- manifest["notarized"] = False142+ manifest["notarized"] = True137143 (assets / manifest_name).write_text(json.dumps(manifest), encoding="utf-8")138144 with self.assertRaisesRegex(VERIFIER.VerificationError, "release manifest"):139145 VERIFIER.validate_assets(repo, "v0.1.0", assets, FakeGit())@@ -222,6 +228,10 @@ class PortalReleaseVerifierTests(unittest.TestCase):
222228 self.assertNotIn("release create", workflow)223229 self.assertNotIn("--draft=false --prerelease", workflow)224230 self.assertNotIn("demo-direct-node", workflow)231+ self.assertNotIn("PORTAL_APPLE_CERTIFICATE_BASE64", workflow)232+ self.assertNotIn("APPLE_SIGNING_IDENTITY", workflow)233+ self.assertNotIn("security create-keychain", workflow)234+ self.assertIn("--signing adhoc", workflow)225235 226236 sigil_workflow = (REPO_DIR / ".github" / "workflows" / "sigil-release.yml").read_text(227237 encoding="utf-8"@@ -234,7 +244,9 @@ class PortalReleaseVerifierTests(unittest.TestCase):
234244 self.assertIn("--signer-workflow", sigil_workflow)235245 self.assertIn("--source-ref", sigil_workflow)236246 self.assertIn("--source-digest", sigil_workflow)237- self.assertIn("portal-apple-team-id.txt", sigil_workflow)247+ self.assertNotIn("portal-apple-team-id.txt", sigil_workflow)248+ self.assertNotIn("PORTAL_APPLE_TEAM_ID", sigil_workflow)249+ self.assertIn("--signing adhoc", sigil_workflow)238250 self.assertLess(239251 sigil_workflow.index("verify-portal-release.py assets"),240252 sigil_workflow.index("--draft=false"),@@ -248,7 +260,8 @@ class PortalReleaseVerifierTests(unittest.TestCase):
248260 self.assertIn('[[ "$architectures" == arm64 ]]', package)249261 self.assertNotIn("--expected-arch", package)250262 self.assertNotIn("--features", package)251- self.assertIn("portal-apple-team-id.txt", package)263+ self.assertIn("--signing adhoc|developer-id is required", package)264+ self.assertIn("Signature=adhoc", package)252265 self.assertIn('[[ "${APPLE_TEAM_ID:-}" == "$expected_team_id" ]]', package)253266 254267 native_verifier = (REPO_DIR / "scripts" / "verify-macos-portal-signature.sh").read_text(@@ -256,6 +269,8 @@ class PortalReleaseVerifierTests(unittest.TestCase):
256269 )257270 self.assertIn("--expected-team-id", native_verifier)258271 self.assertIn('[[ "$team_identifier" == "$expected_team_id" ]]', native_verifier)272+ self.assertIn("Signature=adhoc", native_verifier)273+ self.assertIn("unexpectedly carries a notarization ticket", native_verifier)259274 260275 261276 if __name__ == "__main__":@@ -8,17 +8,19 @@ die() {
88 }99 1010 usage() {11- printf 'Usage: %s --dmg /absolute/path/Portal-VERSION-arm64.dmg --expected-team-id TEAMID [--expected-version VERSION]\n' "$0"11+ printf 'Usage: %s --dmg /absolute/path/Portal-VERSION-arm64.dmg --signing adhoc|developer-id [--expected-team-id TEAMID] [--expected-version VERSION]\n' "$0"1212 }1313 1414 dmg=''1515 expected_version=''1616 expected_team_id=''17+signing_mode=''1718 while (($#)); do1819 case "$1" in1920 --dmg) [[ $# -ge 2 ]] || die "$1 requires a value"; dmg="$2"; shift 2 ;;2021 --expected-version) [[ $# -ge 2 ]] || die "$1 requires a value"; expected_version="$2"; shift 2 ;;2122 --expected-team-id) [[ $# -ge 2 ]] || die "$1 requires a value"; expected_team_id="$2"; shift 2 ;;23+ --signing) [[ $# -ge 2 ]] || die "$1 requires a value"; signing_mode="$2"; shift 2 ;;2224 --help|-h) usage; exit 0 ;;2325 *) die "unknown argument: $1" ;;2426 esac@@ -26,7 +28,18 @@ done
2628 2729 [[ "$(uname -s)" == Darwin ]] || die 'verification must run on macOS'2830 [[ "$dmg" == /* && -f "$dmg" && ! -L "$dmg" ]] || die 'DMG must be an absolute regular file'29-[[ "$expected_team_id" =~ ^[A-Z0-9]{10}$ ]] || die 'expected Apple TeamIdentifier is required'31+case "$signing_mode" in32+ developer-id)33+ [[ "$expected_team_id" =~ ^[A-Z0-9]{10}$ ]] \34+ || die 'expected Apple TeamIdentifier is required for developer-id mode'35+ ;;36+ adhoc)37+ [[ -z "$expected_team_id" ]] \38+ || die '--expected-team-id is meaningless for an ad-hoc release'39+ ;;40+ '') die '--signing adhoc|developer-id is required; there is no default' ;;41+ *) die "unknown signing mode: $signing_mode" ;;42+esac3043 for command_name in codesign find grep hdiutil lipo plutil sed spctl xcrun; do3144 command -v "$command_name" >/dev/null 2>&1 || die "$command_name is required"3245 done@@ -48,8 +61,12 @@ cleanup() {
4861 trap cleanup EXIT INT TERM HUP4962 5063 hdiutil verify "$dmg" >/dev/null51-xcrun stapler validate "$dmg"52-spctl --assess --type open --context context:primary-signature --verbose=4 "$dmg"64+if [[ "$signing_mode" == developer-id ]]; then65+ xcrun stapler validate "$dmg"66+ spctl --assess --type open --context context:primary-signature --verbose=4 "$dmg"67+elif xcrun stapler validate "$dmg" >/dev/null 2>&1; then68+ die 'ad-hoc release unexpectedly carries a notarization ticket'69+fi5370 hdiutil attach "$dmg" -readonly -nobrowse -mountpoint "$mount_path" >/dev/null5471 mounted=true5572 @@ -58,15 +75,28 @@ app_count="$(find "$mount_path" -maxdepth 2 -type d -name 'Portal.app' -print |
5875 app_path="$(find "$mount_path" -maxdepth 2 -type d -name 'Portal.app' -print)"5976 codesign --verify --deep --strict --verbose=4 "$app_path"6077 signature_details="$(codesign -d --verbose=4 "$app_path" 2>&1)"61-grep -Eq '^Authority=Developer ID Application:' <<<"$signature_details" \62- || die 'application is not signed by a Developer ID Application identity'63-team_identifier="$(sed -n 's/^TeamIdentifier=//p' <<<"$signature_details")"64-[[ "$team_identifier" == "$expected_team_id" ]] \65- || die 'application TeamIdentifier does not match the pinned Portal signer'66-grep -Eq '^flags=.*runtime' <<<"$signature_details" \67- || die 'application signature does not enable hardened runtime'68-spctl --assess --type execute --verbose=4 "$app_path"69-xcrun stapler validate "$app_path"78+if [[ "$signing_mode" == developer-id ]]; then79+ grep -Eq '^Authority=Developer ID Application:' <<<"$signature_details" \80+ || die 'application is not signed by a Developer ID Application identity'81+ team_identifier="$(sed -n 's/^TeamIdentifier=//p' <<<"$signature_details")"82+ [[ "$team_identifier" == "$expected_team_id" ]] \83+ || die 'application TeamIdentifier does not match the pinned Portal signer'84+ grep -Eq '^flags=.*runtime' <<<"$signature_details" \85+ || die 'application signature does not enable hardened runtime'86+ spctl --assess --type execute --verbose=4 "$app_path"87+ xcrun stapler validate "$app_path"88+else89+ # The ad-hoc signature is what lets arm64 macOS run the binary at all. It90+ # asserts nothing about origin, so also prove no Apple identity crept in.91+ grep -Fq 'Signature=adhoc' <<<"$signature_details" \92+ || die 'ad-hoc release must carry the mandatory arm64 ad-hoc signature'93+ if grep -q '^Authority=' <<<"$signature_details"; then94+ die 'ad-hoc release unexpectedly carries a certificate authority chain'95+ fi96+ if grep -q '^TeamIdentifier=[A-Z0-9]' <<<"$signature_details"; then97+ die 'ad-hoc release unexpectedly carries an Apple TeamIdentifier'98+ fi99+fi70100 71101 identifier="$(plutil -extract CFBundleIdentifier raw "$app_path/Contents/Info.plist")"72102 [[ "$identifier" == sh.goq.portal ]] || die "unexpected bundle identifier: $identifier"@@ -82,4 +112,5 @@ then
82112 die 'Portal contains a forbidden host or credential artifact'83113 fi84114 85-printf 'portal_signature_verification=ok\nportal_version=%s\nportal_architecture=arm64\n' "$version"115+printf 'portal_signature_verification=ok\nportal_version=%s\nportal_architecture=arm64\nportal_signing=%s\n' \116+ "$version" "$signing_mode"@@ -21,7 +21,12 @@ PRODUCT = "portal-client"
2121 BUNDLE_IDENTIFIER = "sh.goq.portal"2222 PLATFORM = "macos"2323 ARCHITECTURE = "arm64"24-VERIFICATION = "developer-id+hardened-runtime+notarized+stapled+gatekeeper"24+# Portal is distributed without an Apple Developer ID. arm64 macOS refuses to25+# execute a wholly unsigned binary, so the release carries the linker's ad-hoc26+# signature and anchors trust in GitHub/Sigstore build provenance plus the27+# published digest instead of in Gatekeeper.28+SIGNING = "adhoc"29+VERIFICATION = "adhoc-signed+github-attested+sha256"2530 NON_RELEASE_FEATURES = (2631 "demo-direct-node",2732 "experimental-non-macos-pointer-capture",@@ -167,17 +172,18 @@ def expected_release_manifest(
167172 "bundle_identifier": BUNDLE_IDENTIFIER,168173 "checksum_asset": checksum_asset,169174 "demo_direct_node": False,170- "developer_id_signed": True,171- "format": 1,172- "gatekeeper_verified": True,175+ "developer_id_signed": False,176+ "format": 2,177+ "gatekeeper_verified": False,173178 "git_commit": git_commit,174- "hardened_runtime": True,175- "notarized": True,179+ "hardened_runtime": False,180+ "notarized": False,176181 "platform": PLATFORM,177182 "product": PRODUCT,178183 "release_tag": release_tag,179184 "sha256": digest,180- "stapled": True,185+ "signing": SIGNING,186+ "stapled": False,181187 "version": release_version(release_tag),182188 }183189 @@ -63,6 +63,14 @@ grep -Fq 'class="portal-download disabled"' "$site_dir/index.html"
6363 grep -Fq 'id="portal-download" aria-disabled="true"' "$site_dir/index.html"6464 grep -Fq 'window.fetch("./portal-release.json"' "$site_dir/dither.js"6565 grep -Fq 'PORTAL_BUILDS["macos-arm"] = portalBuildFromManifest(manifest)' "$site_dir/dither.js"66+# Portal is not notarized. The site must say so plainly and must not silently67+# start accepting a manifest that claims Apple verification it does not have.68+grep -Fq 'adhoc-signed+github-attested+sha256' "$site_dir/dither.js"69+grep -Fq 'portal is not notarized by apple' "$site_dir/index.html"70+if grep -Fq 'developer-id+hardened-runtime+notarized+stapled+gatekeeper' "$site_dir/dither.js"; then71+ echo 'website must not claim Developer ID notarization' >&272+ exit 173+fi6674 grep -Fq "connect-src 'self'" "$site_dir/_headers"6775 for issue_number in 7 8 9 10; do6876 grep -Fq "github.com/FelineStateMachine/goq/issues/$issue_number" "$site_dir/index.html"@@ -404,13 +404,13 @@
404404 if (typeof version !== "string" || tag !== "v" + version ||405405 build.platform !== "macos" || build.architecture !== "arm64" ||406406 asset !== expectedAsset || build.download_url !== expectedBase + expectedAsset ||407- build.verification !== "developer-id+hardened-runtime+notarized+stapled+gatekeeper") {407+ build.verification !== "adhoc-signed+github-attested+sha256") {408408 throw new Error("invalid available Portal release entry");409409 }410410 return {411411 href: build.download_url,412412 label: "download Portal " + version + " · macOS arm64",413- note: "Portal " + version + " · macOS arm64 · signed, notarized, and Gatekeeper verified",413+ note: "Portal " + version + " · macOS arm64 · not notarized: macOS will block the first launch until you allow it in System Settings › Privacy & Security. Verify the SHA-256 before opening.",414414 };415415 }416416 @@ -80,6 +80,7 @@
8080 </div>8181 <a class="portal-download disabled" id="portal-download" aria-disabled="true">no macos arm64 build yet</a>8282 <p class="release-note"><span id="portal-release-status">portal for macos/arm64 is not released yet · macos arm64 comes first</span> · <a class="release-history" href="https://github.com/FelineStateMachine/goq/releases" rel="noopener">release history</a></p>83+ <p class="release-note">portal is not notarized by apple · macos blocks the first launch, then allow it under system settings › privacy & security · verify the published sha-256 and the github build attestation before you open it</p>8384 </div>8485 <div class="release-action" data-release-target="sigil" hidden>8586 <div class="install" data-copy="curl -fsSL https://goq.sh/install-sigil | bash">