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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
57 changes: 47 additions & 10 deletions .github/workflows/realease-desktop-app.yml
Original file line number Diff line number Diff line change
Expand Up @@ -5,26 +5,30 @@ name: Publish Desktop App

on:
workflow_dispatch:

env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
APPLE_ID: ${{ secrets.APPLE_ID }}
APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
inputs:
prerelease:
description: "Publish as prerelease (autoupdate skips prereleases by default)"
required: false
type: boolean
default: false

jobs:
build:
environment: release
strategy:
fail-fast: false
matrix:
os:
- name: linux
image: ubuntu-latest
- name: windows
image: windows-latest

# - name: macos-apple-silicon
# image: macos-15
- name: macos-apple-silicon
image: macos-15
runs-on: ${{ matrix.os.image }}
env:
HAS_SIGNING: ${{ secrets.APPLE_CERTIFICATE_P12 != '' && secrets.APPLE_CERTIFICATE_PASSWORD != '' && secrets.APPLE_SIGNING_IDENTITY != '' }}
HAS_NOTARIZE: ${{ secrets.APPLE_API_KEY_P8 != '' && secrets.APPLE_API_KEY_ID != '' && secrets.APPLE_API_ISSUER != '' }}
defaults:
run:
working-directory: ./desktop
Expand Down Expand Up @@ -54,14 +58,47 @@ jobs:
- name: Install Dependencies
run: pnpm install --frozen-lockfile

- name: Import Apple signing certificate
if: ${{ matrix.os.name == 'macos-apple-silicon' && env.HAS_SIGNING == 'true' }}
uses: apple-actions/import-codesign-certs@v2
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot May 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

# Check if the workflow file exists and inspect the relevant section
find . -name "realease-desktop-app.yml" -o -name "*release*desktop*"

Repository: gridaco/grida

Length of output: 102


🏁 Script executed:

# Also check the exact path provided
cat -n ".github/workflows/realease-desktop-app.yml" | sed -n '55,70p'

Repository: gridaco/grida

Length of output: 802


🏁 Script executed:

# Get the full file to understand context
wc -l ".github/workflows/realease-desktop-app.yml"

Repository: gridaco/grida

Length of output: 104


🌐 Web query:

GitHub Actions pinning commit SHA security best practice

💡 Result:

Pinning GitHub Actions to a full-length commit SHA is a critical security best practice to prevent supply chain attacks, such as those involving hijacked tags or compromised maintainer accounts [1][2][3][4]. Why Pinning to SHA is Required Standard version tags (e.g., @v1, @v4) are mutable labels in Git that can be moved, deleted, or force-pushed to point to different, potentially malicious commits [5][3][6]. If a repository hosting an action is compromised, an attacker can modify a tag to inject malicious code while the workflow continues to use the same tag name [7][5][8]. Pinning to an immutable, full-length commit SHA ensures that the exact code you have audited and validated is executed every time, regardless of subsequent changes to the action's repository [9][2][3][6]. Implementation Best Practices - Use Full-Length SHAs: Always use the complete commit SHA (e.g., actions/checkout@f43a0e5) [2]. - Maintain Human Readability: Include the original version tag as a comment to keep workflows understandable (e.g., uses: actions/checkout@f43a0e5 # v3.6.0) [9][3][10]. - Automate Updates: Use dependency management tools like Dependabot or Renovate to help manage and update these pinned SHAs, balancing security with the need to receive bug fixes and features [1][10][4]. - Adopt "Cooldowns": Even with pinning, consider introducing a delay (e.g., 7–14 days) before adopting new action versions to mitigate exposure to zero-day supply chain attacks [4]. Enforcement Policies GitHub now provides organization and repository-level policies that allow administrators to enforce SHA pinning [11][9][2]. When enabled, any workflow attempting to use a mutable reference (like a branch or tag) will fail, forcing compliance across the environment [11][3][4]. Administrators can also use blocking policies (using the! prefix) to instantly blacklist specific compromised actions or versions organization-wide [11][9][4]. It is important to note that pinning provides defense against repository compromise but does not protect against vulnerabilities present in the specific version of the code you have pinned [6][4]. Always prioritize using verified, official actions and periodically review your dependencies [2][6][4].

Citations:


🌐 Web query:

apple-actions/import-codesign-certs v2 commit SHA

💡 Result:

For apple-actions/import-codesign-certs v2, I could not retrieve the specific underlying commit SHA with the evidence available from the sources I searched. What I found: - The repository exists and is currently documented/used with major tags like v3, but the sources I retrieved don’t include the mapping of v2 -> exact commit SHA (they show the default branch and the current release listing without enough detail captured to identify the v2 tag’s commit) [1][2]. - A third-party/fork page explicitly shows usage with @v2 in its “Usage” section, but it still does not provide the commit SHA for that tag [3]. If you want, tell me whether you mean: 1) the commit SHA that the git tag “v2” points to in Apple-Actions/import-codesign-certs, or 2) the commit SHA that GitHub Actions resolves when you write uses: apple-actions/import-codesign-certs@v2. Either way, I can then do one more targeted lookup on the release/tag page details for v2 to extract the exact SHA.

Citations:


🏁 Script executed:

# Attempt to fetch the apple-actions/import-codesign-certs repository to find the v2 tag's commit SHA
# We can use git or curl to check the GitHub API
curl -s "https://api.github.com/repos/Apple-Actions/import-codesign-certs/git/refs/tags/v2" | jq '.object.sha' 2>/dev/null || echo "API call may have failed or tag not found"

Repository: gridaco/grida

Length of output: 100


🏁 Script executed:

# Alternative: check if git can resolve the tag
# Since we may not have direct internet access, let's verify the recommendation is sound
# by checking if there are any other GitHub Actions in this workflow that ARE pinned to commit SHAs
grep -n "uses:" ".github/workflows/realease-desktop-app.yml" | head -20

Repository: gridaco/grida

Length of output: 227


Pin apple-actions/import-codesign-certs to a full commit SHA.

Using @v2 is mutable and weakens workflow supply-chain integrity. Version tags can be rewritten or compromised to inject malicious code.

Suggested fix
- uses: apple-actions/import-codesign-certs@v2
+ uses: apple-actions/import-codesign-certs@8f3fb608891dd2244cdab3d69cd68c0d37a7fe93 # v2
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
uses: apple-actions/import-codesign-certs@v2
uses: apple-actions/import-codesign-certs@8f3fb608891dd2244cdab3d69cd68c0d37a7fe93 # v2
🧰 Tools
🪛 zizmor (1.25.2)

[error] 63-63: unpinned action reference (unpinned-uses): action is not pinned to a hash (required by blanket policy)

(unpinned-uses)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In @.github/workflows/realease-desktop-app.yml at line 63, Replace the mutable
tag "apple-actions/import-codesign-certs@v2" with a pinned full commit SHA
reference for that action (e.g.,
"apple-actions/import-codesign-certs@<full-commit-sha>") to ensure immutability;
locate the workflow step using the action name and update the uses field to the
specific commit SHA obtained from the action's repository to hardpin the exact
code version.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Skipping — repo convention is version tags across every workflow (actions/checkout@v4, pnpm/action-setup@v4, dtolnay/rust-toolchain@stable, etc.). Pinning just this one action to a SHA would be inconsistent. Cross-cutting supply-chain hygiene is worth a separate org-wide policy decision.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@softmarshmallow, fair point — piecemeal SHA pinning on a single action while the rest of the repo uses version tags would be inconsistent and potentially confusing for maintainers. Acknowledging and dropping the suggestion.

Would you like me to open a follow-up issue to track the org-wide supply-chain hygiene / SHA-pinning policy decision so it doesn't get lost?


✏️ Learnings added
Learnt from: softmarshmallow
Repo: gridaco/grida PR: 726
File: .github/workflows/realease-desktop-app.yml:63-63
Timestamp: 2026-05-20T10:38:25.230Z
Learning: In the gridaco/grida repository, the convention across all GitHub Actions workflows is to reference actions using mutable version tags (e.g., `actions/checkoutv4`, `pnpm/action-setupv4`, `dtolnay/rust-toolchainstable`) rather than pinning to full commit SHAs. Do not flag individual unpinned action references as issues; supply-chain hygiene via SHA pinning is an intentional org-wide policy decision deferred to a separate track.

Note: Learnings are effective only in the context of similar code segments. To apply general coding guidelines, please configure review instructions. You can manage existing learnings in the app.

with:
keychain: build
p12-file-base64: ${{ secrets.APPLE_CERTIFICATE_P12 }}
p12-password: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}

- name: Stage App Store Connect API key
if: ${{ matrix.os.name == 'macos-apple-silicon' && env.HAS_NOTARIZE == 'true' }}
shell: bash
env:
APPLE_API_KEY_P8: ${{ secrets.APPLE_API_KEY_P8 }}
APPLE_API_KEY_ID: ${{ secrets.APPLE_API_KEY_ID }}
run: |
DIR="$RUNNER_TEMP/keys"
mkdir -p "$DIR"
KEY_PATH="$DIR/AuthKey_${APPLE_API_KEY_ID}.p8"
printf '%s' "$APPLE_API_KEY_P8" > "$KEY_PATH"
echo "APPLE_API_KEY=$KEY_PATH" >> "$GITHUB_ENV"

- name: Publish App (Windows)
if: ${{ matrix.os.name == 'windows' }}
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
PRERELEASE: ${{ inputs.prerelease }}
run: pnpm run publish:prerelease --arch="x64"

- name: Publish App (Linux)
if: ${{ matrix.os.name == 'linux' }}
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
PRERELEASE: ${{ inputs.prerelease }}
run: pnpm run publish:prerelease --arch="x64,arm64"

- name: Publish App (MacOS)
if: ${{ matrix.os.name == 'macos-apple-silicon' }}
if: ${{ matrix.os.name == 'macos-apple-silicon' && env.HAS_SIGNING == 'true' && env.HAS_NOTARIZE == 'true' }}
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
PRERELEASE: ${{ inputs.prerelease }}
APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }}
APPLE_API_KEY_ID: ${{ secrets.APPLE_API_KEY_ID }}
APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }}
run: pnpm run publish:prerelease --arch="x64,arm64"
9 changes: 9 additions & 0 deletions desktop/build/entitlements.mac.plist
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.cs.allow-jit</key><true/>
<key>com.apple.security.cs.allow-unsigned-executable-memory</key><true/>
<key>com.apple.security.cs.disable-library-validation</key><true/>
</dict>
</plist>
41 changes: 34 additions & 7 deletions desktop/forge.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { VitePlugin } from "@electron-forge/plugin-vite";
import { FusesPlugin } from "@electron-forge/plugin-fuses";
import { FuseV1Options, FuseVersion } from "@electron/fuses";
import * as dotenv from "dotenv";
import path from "node:path";

// cli flags
const IS_INSIDERS = process.argv.includes("--insiders");
Expand All @@ -27,6 +28,36 @@ const productName = IS_INSIDERS ? "Grida Insiders" : "Grida";
const appBundleId = IS_INSIDERS ? "co.grida.insiders" : "co.grida.desktop";
const icon = IS_INSIDERS ? "./images/insiders/icon" : "./images/icon";

const signingIdentity = process.env.APPLE_SIGNING_IDENTITY;
const entitlementsPath = path.join(
__dirname,
"build",
"entitlements.mac.plist"
);

const osxSign = signingIdentity
? {
identity: signingIdentity,
optionsForFile: () => ({
hardenedRuntime: true,
entitlements: entitlementsPath,
"entitlements-inherit": entitlementsPath,
"gatekeeper-assess": false,
}),
}
: undefined;

const osxNotarize =
process.env.APPLE_API_KEY &&
process.env.APPLE_API_KEY_ID &&
process.env.APPLE_API_ISSUER
? {
appleApiKey: process.env.APPLE_API_KEY,
appleApiKeyId: process.env.APPLE_API_KEY_ID,
appleApiIssuer: process.env.APPLE_API_ISSUER,
}
: undefined;

const config: ForgeConfig = {
packagerConfig: {
extraResource: [
Expand All @@ -38,12 +69,8 @@ const config: ForgeConfig = {
asar: true,
appBundleId: appBundleId,
icon: icon,
osxSign: {},
osxNotarize: {
appleId: process.env.APPLE_ID ?? "",
appleIdPassword: process.env.APPLE_PASSWORD ?? "",
teamId: process.env.APPLE_TEAM_ID ?? "",
},
osxSign,
osxNotarize,
win32metadata: {
CompanyName: "Grida Inc.",
},
Expand Down Expand Up @@ -148,7 +175,7 @@ const config: ForgeConfig = {
owner: "gridaco",
name: "grida",
},
prerelease: true,
prerelease: process.env.PRERELEASE === "true",
},
},
],
Expand Down
5 changes: 5 additions & 0 deletions desktop/pnpm-workspace.yaml
Original file line number Diff line number Diff line change
@@ -1,2 +1,7 @@
packages:
- .
onlyBuiltDependencies:
- electron
- electron-winstaller
- fs-xattr
- macos-alias
2 changes: 1 addition & 1 deletion desktop/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ app.commandLine.appendSwitch("disable-async-dns");
app.commandLine.appendSwitch("js-flags", "--expose-gc");
// #endregion chrome flags

updateElectronApp();
updateElectronApp({ notifyUser: true });

app.setName("Grida");
// Handle creating/removing shortcuts on Windows when installing/uninstalling.
Expand Down
160 changes: 160 additions & 0 deletions docs/contributing/desktop-release.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
---
format: md
---

# Contributing to Grida | Desktop release
Comment on lines +1 to +5
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot May 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Move this runbook to an actively maintained docs path.

This file is added under docs/contributing/, which conflicts with the repository docs maintenance policy.

As per coding guidelines, docs/**/*.md: "the root ./docs directory is the source of truth, and only actively maintain docs/wg/** and docs/reference/** directories".

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@docs/contributing/desktop-release.md` around lines 1 - 5, The new runbook
titled "Contributing to Grida | Desktop release" (desktop-release.md) must be
moved out of docs/contributing into one of the approved actively maintained
directories (docs/wg/ or docs/reference/); relocate the file to the appropriate
docs/wg or docs/reference location, update any internal links or TOC entries
that referenced the old path, and remove the original docs/contributing copy so
the repository follows the docs maintenance policy.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Skipping — docs/contributing/ is an established path with siblings (billing.md, react.md, wpt.md), and this page was explicitly requested under it. The "actively maintained" list in docs/AGENTS.md governs broad SEO/frontmatter churn priorities, not which paths can hold new content.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@softmarshmallow, thanks for the clarification — that's a good distinction. The docs/AGENTS.md "actively maintained" list governs broad SEO/frontmatter maintenance priorities, not a restriction on which paths may receive new content. docs/contributing/ is an established directory and placing this runbook there alongside its existing siblings is entirely appropriate.


✏️ Learnings added
Learnt from: softmarshmallow
Repo: gridaco/grida PR: 726
File: docs/contributing/desktop-release.md:1-5
Timestamp: 2026-05-20T10:38:25.655Z
Learning: In the gridaco/grida repository, the "actively maintained" list in `docs/AGENTS.md` (covering `docs/wg/**` and `docs/reference/**`) governs SEO/frontmatter churn priorities only — it does not restrict which paths can hold new content. `docs/contributing/` is an established directory with sibling files (`billing.md`, `react.md`, `wpt.md`) and is a valid location for new contributor documentation such as runbooks.

Note: Learnings are effective only in the context of similar code segments. To apply general coding guidelines, please configure review instructions. You can manage existing learnings in the app.


Runbook for cutting desktop releases — macOS / Windows / Linux signed, notarized, autoupdating via [`update.electronjs.org`](https://github.com/electron/update.electronjs.org). Target audience: maintainers with `release` environment approver rights.

> Triggering the workflow does **not** ship anything until an approver clicks "Approve and deploy" in the [`release` environment](https://github.com/gridaco/grida/settings/environments) gate.

---

## Cutting a release

1. **Bump the version.** Edit [`desktop/package.json`](https://github.com/gridaco/grida/blob/main/desktop/package.json) `"version"` and commit on `main`. Plain semver only (`0.2.0`, not `desktop-v0.2.0`) — the autoupdate feed runs `semver.valid()` on the tag and silently skips anything else. Don't reuse an existing tag.

2. **Trigger the workflow.**

```sh
gh workflow run realease-desktop-app.yml -R gridaco/grida -f prerelease=false
```

Or via UI: **Actions → Publish Desktop App → Run workflow**.

3. **Approve in the environment gate.** Each platform job (mac/win/linux) pauses at the `release` environment. Open the run in Actions and click "Review deployments → Approve and deploy."

4. **Verify.** When the run finishes, the GitHub Release page has `Grida-darwin-arm64-<version>.zip`, `Grida-darwin-x64-<version>.zip`, the `.dmg` siblings, Windows `.exe` + nupkg, and Linux `.deb` / `.rpm`. Confirm the feed serves it:

```sh
BASE=https://update.electronjs.org/gridaco/grida/darwin-arm64
curl -s -w "\n%{http_code}\n" "$BASE/0.0.0"
# WANT: 200 + JSON pointing at the new .zip

curl -s -o /dev/null -w "%{http_code}\n" "$BASE/<just-released-version>"
# WANT: 204 (no update — correct)
```

If the old-client request returns 204, either the release is marked prerelease or the tag isn't valid semver. See [Troubleshooting](#troubleshooting).

## Cutting a prerelease

Same as above but with `-f prerelease=true`. The release shows up on GitHub Releases marked as a prerelease and `update.electronjs.org` **skips it for installed users** — prereleases are for manual download / testing only. Useful when you want a build out for QA without auto-shipping it to everyone.

To promote a prerelease to stable: edit the GitHub Release in the UI and uncheck "Set as a pre-release." The feed will pick it up on the next poll (~6h default).

---

## What lives where

| Concern | File / location |
| ----------------------------- | ------------------------------------------------------------------------------------------------------------------------------------- |
| Workflow | [`.github/workflows/realease-desktop-app.yml`](https://github.com/gridaco/grida/blob/main/.github/workflows/realease-desktop-app.yml) |
| Build + signing config | [`desktop/forge.config.ts`](https://github.com/gridaco/grida/blob/main/desktop/forge.config.ts) |
| Hardened-runtime entitlements | [`desktop/build/entitlements.mac.plist`](https://github.com/gridaco/grida/blob/main/desktop/build/entitlements.mac.plist) |
| In-app updater wiring | [`desktop/src/main.ts`](https://github.com/gridaco/grida/blob/main/desktop/src/main.ts) — `updateElectronApp({ notifyUser: true })` |
| pnpm native-module allowlist | [`desktop/pnpm-workspace.yaml`](https://github.com/gridaco/grida/blob/main/desktop/pnpm-workspace.yaml) — `onlyBuiltDependencies` |
| Apple secrets | `release` environment on `gridaco/grida` — `gh secret list --env release --repo gridaco/grida` |

Secrets (all 6 env-scoped, not repo-wide):

```text
APPLE_CERTIFICATE_P12 base64 of the .p12
APPLE_CERTIFICATE_PASSWORD .p12 export password
APPLE_SIGNING_IDENTITY "Developer ID Application: <Name> (TEAMID)" (no quotes)
APPLE_API_KEY_P8 raw .p8 PEM contents
APPLE_API_KEY_ID 10-char Key ID (also in .p8 filename)
APPLE_API_ISSUER Issuer UUID — App Store Connect → Integrations
```

Rotating any of these is a `gh secret set <NAME> --env release --repo gridaco/grida` away. The code reads from env vars only — no code changes needed when secrets rotate.

---

## Hard constraints

These are silent footguns. Pinned, do not change without a plan:

- **`appBundleId: "co.grida.desktop"`** in `forge.config.ts`. Changing it strands every installed user (Squirrel won't apply updates across bundle-id boundaries). Insiders use `co.grida.insiders` — separate track on purpose.
- **Plain semver tags.** `update.electronjs.org` skips tags that don't pass `semver.valid()`. No `desktop-v…` or other prefixes.
- **`hardenedRuntime: true`** in `osxSign.optionsForFile`. Required for notarization; required for the entitlements plist to apply.
- **`onlyBuiltDependencies` in `pnpm-workspace.yaml`** (not `package.json`). pnpm 10 silently disables native module builds without it; in pnpm 10, when both files exist, only the workspace file's list is consulted. Removing it breaks the DMG maker (`Cannot find module '../build/Release/volume.node'`).

---

## Troubleshooting

**`update.electronjs.org` returns 204 for an old client → feed not serving the new release.**

1. Release marked prerelease — uncheck in the UI or re-publish with `prerelease=false`.
2. Tag not valid semver — re-tag with a valid version.
3. No asset matching `<platform>-<arch>` (e.g. `darwin-arm64`) on the release. Check artifact filenames in forge `makers` config.

**Workflow logs: `Cannot find module '../build/Release/volume.node'`**
`onlyBuiltDependencies` is missing or in the wrong file. Must be in `desktop/pnpm-workspace.yaml`, not `desktop/package.json`. See [Hard constraints](#hard-constraints).

**Workflow logs: `Error parsing workflow file ... HTTP 422` on `workflow_dispatch`.**
A step `if:` references `${{ secrets.* }}` directly — not allowed. Map secrets to job-level `env:` booleans (`HAS_SIGNING: ${{ secrets.APPLE_CERTIFICATE_P12 != '' }}`) and gate on `env.HAS_SIGNING == 'true'`.

**Notarization rejects the upload (`The signature does not include a secure timestamp` or similar).**
Hardened runtime not enabled, or entitlements plist not applied. Check `forge.config.ts` `osxSign.optionsForFile` returns `hardenedRuntime: true` and points at [`build/entitlements.mac.plist`](https://github.com/gridaco/grida/blob/main/desktop/build/entitlements.mac.plist).

**Squirrel error in user logs: `Code signature at URL ... did not pass validation`.**
The downloaded update's signature doesn't satisfy the installed app's Designated Requirement. Most likely the Team ID changed — see [Apple credential rotation](#apple-credential-rotation). For the existing fleet, a manual reinstall is the only path forward.

---

## Local end-to-end test (when changing release config)

When editing `forge.config.ts`, `entitlements.mac.plist`, the workflow, or the pnpm allowlist — run a full local signed+notarized build before merging. Catches the same failures CI would hit without burning the notary quota or polluting the Releases tab.

Assumes you have the cert and API key under `~/.applesecrets/` (or wherever; adjust paths).

```sh
# Temp keychain — mirrors what apple-actions/import-codesign-certs does in CI
KEYCHAIN="$TMPDIR/grida-build.keychain-db"
KCPASS="$(uuidgen)"
security create-keychain -p "$KCPASS" "$KEYCHAIN"
security set-keychain-settings -lut 21600 "$KEYCHAIN"
security unlock-keychain -p "$KCPASS" "$KEYCHAIN"
security import ~/.applesecrets/certificate.p12 \
-P "$(tr -d '\n' < ~/.applesecrets/p12password)" \
-A -t cert -f pkcs12 -k "$KEYCHAIN" >/dev/null
security set-key-partition-list -S apple-tool:,apple: -s -k "$KCPASS" "$KEYCHAIN" >/dev/null
EXISTING="$(security list-keychains -d user | tr -d '"' | xargs)"
security list-keychains -d user -s "$KEYCHAIN" $EXISTING

# Env (forge reads these)
export APPLE_SIGNING_IDENTITY="$(tr -d '"\n' < ~/.applesecrets/codesigning-identity-string-with-quotes)"
export APPLE_API_KEY="$HOME/.applesecrets/AuthKey_XXXXXXXXXX.p8"
export APPLE_API_KEY_ID="XXXXXXXXXX"
export APPLE_API_ISSUER="<uuid>"

# Build — does NOT publish
cd desktop && pnpm make --arch=arm64

# Verify the .app
APP="out/Grida-darwin-arm64/Grida.app"
codesign --verify --deep --strict --verbose=2 "$APP" # valid on disk + satisfies DR
spctl -a -vvv -t install "$APP" # accepted + source=Notarized Developer ID
xcrun stapler validate "$APP" # "validate action worked"

# Cleanup
security list-keychains -d user -s $EXISTING
security delete-keychain "$KEYCHAIN"
```

Notarization takes 1–5 minutes. Forge calls `notarytool` and waits.

> `pnpm make` builds locally only. `pnpm run publish:prerelease` pushes to GitHub Releases — **don't run that locally.** Releases go through the workflow.

---

## Apple credential rotation

The 6 secrets above are independent of code. Rotating a cert, switching the App Store Connect API key, or moving to a different Apple Developer account is a `gh secret set` for each value. No code changes.

> [!WARNING]
> If the new cert has a **different Team ID** from the previous one, autoupdate breaks for existing installs: Squirrel.Mac validates each update against the installed app's Designated Requirement (which includes Team ID), the check fails, the update is silently rejected. Existing users keep working on their last build with the old Team ID but stop receiving updates — they must manually re-download from grida.co to get on the new track.
>
> If a Team ID change is planned: add an in-app banner ("this version stopped receiving updates, please reinstall") _before_ you rotate, so existing users see your messaging while still on the old build. `appBundleId` is independent of Team ID and stays stable across rotations.
Loading