mirror of
https://github.com/stablyai/orca.git
synced 2026-10-31 00:57:45 +00:00
* Add Orca's own bundled-skill planner and installer (not wired in yet) One planner decides what installing Orca's bundled skills does to each target folder; execute re-plans under per-path locks and runs only a plan whose digest still matches; a shared summary derives badge, wording and exit code. A copy counts as Orca's untouched one only when its SKILL.md bytes match a version some build shipped (resources/skills/released-digests.json, generated from git history and checked add-only). Nothing calls this yet; the npx path is unchanged. * Report left-alone folders as warnings and keep backups in the home A leave is no longer a failure: the outcome line says what happened plus "N left alone: <reason>", and the exit code is 1 only for a failed or changed op. Backups for every scope go to <home>/.agents/.orca-skill-backups on the executing host, so a project Replace never writes into the repo; moving there falls back to copy, verify, then remove across filesystems. * Update Orca's skills with Orca's own installer instead of npx skills The app's Update skills dialog, badge, nudge and status pills now read the bundled-skill plan and summary, and Update executes the reviewed plan's digest. `orca skills install|update` runs the same planner in-process with --dry-run, --force, --json and per-summary exit codes. Removes both npx spawn sites, the freshness inventory modules and the npx round-trip CI. * Update only where the skill already is; one provider-root rule for app and CLI installedOnly now drops every location that holds nothing (keeping the shared slot only when an existing agent link points at it), so Update never adds a skill for another agent. The app and CLI resolve the Claude skills folder through the same rule in localBundledSkillInput; a test asserts both plan identically. * Compact Update skills dialog: one row per skill, locations named by agent summarizeBundledSkillPlan now returns each skill's locations, labelled by agent (or "Shared folder") with a ~-collapsed path; the dialog shows one row per skill that needs something, collapses up-to-date skills into one line, and lists every location behind "Show all locations". The result reads "Updated N skills · M left alone". Under installedOnly a Replace with no shared copy replaces in place instead of adding one. * fix(skills): keep location paths readable by putting the state under the path * fix(skills): hold the last plan in state, not a ref mutated during render Split the update dialog's rows, headline and footer into components, move the Update label next to the other plan copy, and use z.strictObject for the request schemas. * Escape non-ASCII characters in bundled-skill regex and wording The orcad bundle rejects a literal non-ASCII character in a regex. * fix(cli): escape non-ASCII apostrophes in the skills install help text * test(orcad): check the bundled CLI plans a skill install without npx Drop the deleted roundtrip workflow's group from the closed-PR cancellation fixture. * Make bundled-skill test fixtures link folders the way the product does on Windows Directory links in fixtures are junctions on Windows, which need no Developer Mode; the one file-link fixture is skipped there. * Resolve legacy skill agent keys canonically * Reject stale skill replacements when auxiliary content changes * Keep ordinary bundled skill classification shallow * Leave unreadable skill replacements alone without aborting plans * Pass force-all through shallow planning and describe unreadable copies * Clarify skill update statuses and folder details * Keep selected skill replacements in place with one status * Report completed skill replacements with their backup confirmation * Clarify available skill updates and skipped copies * Keep skill update results in preview order * Show each skill skip reason once * Count skipped skills instead of skipped folders * Make installer input and link tests work on Windows * Bound bundled skill classification reads and parse names linearly * Keep workspace skill roots inside the resolved workspace * Compare workspace skill roots with native path semantics * Restrict bundled skill backups to their owner on POSIX * Keep recovered skills needing attention until they are updated * Count completed skill updates in the status tooltip * Sync the runtime localization catalog
2612 lines
123 KiB
YAML
2612 lines
123 KiB
YAML
name: Cut Release
|
|
|
|
# Why: single entry point for manually cutting releases.
|
|
# Replaces the old local `pnpm release:*` scripts and the standalone scheduled
|
|
# RC workflow so releases are always reproducible from CI and can never be
|
|
# accidentally tagged against an uncommitted or non-main working tree.
|
|
#
|
|
# Flow:
|
|
# 1. Resolve `ref` to a SHA.
|
|
# 2. Read the latest stable release from GitHub.
|
|
# 3. Compute the next version from `kind` (rc | patch | minor | major).
|
|
# 4. For stable kinds, REFUSE if the new version is <= the latest stable.
|
|
# This is the only guard electron-updater actually needs — it compares
|
|
# semver within a channel, so a regressing "latest" is the one thing
|
|
# that breaks auto-update for fresh installs.
|
|
# 5. Write package.json, commit (detached), tag, push tag.
|
|
# 6. If ref was the tip of origin/main, try to fast-forward its version;
|
|
# a pull-request-only rule leaves main unchanged and still pushes the tag.
|
|
# 7. Build and publish artifacts from the tag.
|
|
|
|
on:
|
|
workflow_dispatch:
|
|
inputs:
|
|
kind:
|
|
description: Release kind
|
|
required: true
|
|
type: choice
|
|
default: rc
|
|
options:
|
|
- rc
|
|
- patch
|
|
- minor
|
|
- major
|
|
ref:
|
|
description: Branch, tag, or SHA to release from (default main)
|
|
required: false
|
|
type: string
|
|
default: main
|
|
dry_run:
|
|
description: Validate an RC release cut without creating a tag
|
|
required: false
|
|
default: false
|
|
type: boolean
|
|
version_suffix:
|
|
description: Extra prerelease identifier appended to an rc version (e.g. "perf" -> 1.2.3-rc.4.perf). Applies to kind=rc, or to an explicit version that is a bare X.Y.Z-rc.N.
|
|
required: false
|
|
type: string
|
|
default: ''
|
|
version:
|
|
description: Exact version to cut (e.g. 1.4.155 or 1.4.155-rc.4), bypassing kind-based computation. Use to leapfrog a deleted/rolled-back stable that regressed the release list. Must be greater than the latest published stable, and an -rc.N must be above the highest RC already cut for its own base.
|
|
required: false
|
|
type: string
|
|
default: ''
|
|
|
|
permissions:
|
|
contents: read
|
|
|
|
concurrency:
|
|
group: release-cut
|
|
cancel-in-progress: false
|
|
|
|
jobs:
|
|
cut:
|
|
# Why: this job bumps package.json and fast-forwards main. On a fork with
|
|
# Actions enabled, the scheduled cut would run against the fork's main and
|
|
# diverge it (version line) every slot, conflicting every PR back upstream.
|
|
# Gate to the canonical repo so the workflow no-ops on forks.
|
|
if: github.repository == 'stablyai/orca'
|
|
runs-on: ubuntu-latest
|
|
timeout-minutes: 15
|
|
permissions:
|
|
contents: write
|
|
outputs:
|
|
tag: ${{ steps.tag.outputs.tag || steps.version.outputs.recovered_tag }}
|
|
should_release: ${{ steps.tag.outputs.tag != '' || steps.version.outputs.recovered_tag != '' }}
|
|
latest_published_rc_tag: ${{ steps.publish_drafts.outputs.latest_published_tag }}
|
|
# Why: downstream SignPath Slack pings need the cut source (not only the
|
|
# new tag) so approvers know what they're signing and who cut it.
|
|
source_ref: ${{ steps.resolve.outputs.ref }}
|
|
source_sha: ${{ steps.resolve.outputs.sha }}
|
|
source_short_sha: ${{ steps.resolve.outputs.short_sha }}
|
|
ships_orcad_template: ${{ steps.orcad-template-support.outputs.ships }}
|
|
steps:
|
|
# Why inlined (not m-s-abeer/update-gha-summary-with-workflow-inputs):
|
|
# this job runs with contents:write and secret scope, so avoid executing
|
|
# any external (mutable @v1) action here. Surfaces every
|
|
# workflow_dispatch input as a table for audit; the resolved commit /
|
|
# branch / tag enrichment is written later in "Resolve ref SHA".
|
|
# Inputs are passed as JSON via env and parsed by jq as data — never
|
|
# interpolated into the shell — to avoid injection from dispatch values.
|
|
- name: Summarize workflow inputs
|
|
if: github.event_name == 'workflow_dispatch'
|
|
env:
|
|
INPUTS_JSON: ${{ toJSON(inputs) }}
|
|
run: |
|
|
{
|
|
echo "## Workflow inputs"
|
|
echo ""
|
|
echo "| Input | Value |"
|
|
echo "| --- | --- |"
|
|
# Values are data from env JSON; wrap in backticks for readability.
|
|
# Newlines collapsed so a multi-line input cannot break the table.
|
|
jq -r '(. // {}) | to_entries[] | "| `\(.key)` | `\(.value | tostring | gsub("\n"; " "))` |"' <<<"$INPUTS_JSON"
|
|
} >> "$GITHUB_STEP_SUMMARY"
|
|
|
|
- name: Checkout ref
|
|
uses: actions/checkout@v6
|
|
with:
|
|
ref: ${{ github.event_name == 'schedule' && 'main' || inputs.ref }}
|
|
fetch-depth: 0
|
|
# Version recovery needs complete history and tags, but not historical file contents.
|
|
filter: blob:none
|
|
# Why: version math recovers unpublished tags; checkout's default
|
|
# fetch-tags:false hides them, so a patch cut recreates vX.Y.Z and
|
|
# `git push` overwrites the existing tag.
|
|
fetch-tags: true
|
|
|
|
# Why: a relay-era ref (before #27215) needs the Windows relay addon this build no longer compiles.
|
|
- name: Refuse a ref that still ships the SSH relay
|
|
shell: bash
|
|
run: |
|
|
if [[ -e config/tsconfig.relay.json ]]; then
|
|
echo "::error::This ref predates #27215 and still ships the SSH relay; build a commit at or after 2b4b60a87e."
|
|
exit 1
|
|
fi
|
|
|
|
- name: Setup Node.js
|
|
uses: actions/setup-node@v6
|
|
with:
|
|
node-version-file: package.json
|
|
check-latest: true
|
|
|
|
- name: Configure git author
|
|
run: |
|
|
git config user.name "github-actions[bot]"
|
|
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
|
|
|
|
- name: Resolve ref SHA
|
|
id: resolve
|
|
env:
|
|
# Why: keep the caller's ref as data (env) so we can label it in the
|
|
# summary without shell-interpolating a dispatch-controlled string
|
|
# into the script body.
|
|
INPUT_REF: ${{ github.event_name == 'schedule' && 'main' || inputs.ref }}
|
|
REPO: ${{ github.repository }}
|
|
SERVER_URL: ${{ github.server_url }}
|
|
run: |
|
|
set -euo pipefail
|
|
input_ref="${INPUT_REF:-main}"
|
|
sha="$(git rev-parse HEAD)"
|
|
short_sha="$(git rev-parse --short=12 HEAD)"
|
|
echo "ref=$input_ref" >>"$GITHUB_OUTPUT"
|
|
echo "sha=$sha" >>"$GITHUB_OUTPUT"
|
|
echo "short_sha=$short_sha" >>"$GITHUB_OUTPUT"
|
|
|
|
# Why: only push the version-bump commit back to main when the
|
|
# caller is releasing the exact tip of main. For any older or
|
|
# off-main ref we leave main alone and only publish the tag.
|
|
git fetch origin main --quiet
|
|
main_sha="$(git rev-parse origin/main)"
|
|
if [[ "$sha" == "$main_sha" ]]; then
|
|
echo "push_main=true" >>"$GITHUB_OUTPUT"
|
|
else
|
|
echo "push_main=false" >>"$GITHUB_OUTPUT"
|
|
fi
|
|
|
|
# Always surface the resolved commit in the job summary, plus any
|
|
# branches/tags that currently point at it (clickable). The raw
|
|
# `ref` input alone is ambiguous (branch vs tag vs SHA); for SHA
|
|
# inputs it also hides the human-readable names operators need
|
|
# when auditing RC cuts.
|
|
repo_url="${SERVER_URL}/${REPO}"
|
|
|
|
branches="$(
|
|
git for-each-ref --format='%(refname:short)' --points-at="$sha" 'refs/remotes/origin/*' \
|
|
| sed 's|^origin/||' \
|
|
| grep -vx 'HEAD' \
|
|
| sort -u \
|
|
|| true
|
|
)"
|
|
tags="$(
|
|
git for-each-ref --format='%(refname:short)' --points-at="$sha" 'refs/tags/*' \
|
|
| sort -u \
|
|
|| true
|
|
)"
|
|
|
|
# Build comma-separated markdown links. Branch/tag names go in the
|
|
# URL path as-is (slashes must stay literal for GitHub tree URLs).
|
|
linkify_names() {
|
|
local url_kind="$1"
|
|
local names="$2"
|
|
if [[ -z "${names//[$'\t\r\n']/}" ]]; then
|
|
printf '_none_'
|
|
return
|
|
fi
|
|
local first=1
|
|
while IFS= read -r name; do
|
|
[[ -z "$name" ]] && continue
|
|
local path_name url
|
|
path_name="${name// /%20}"
|
|
case "$url_kind" in
|
|
branch) url="${repo_url}/tree/${path_name}" ;;
|
|
tag) url="${repo_url}/releases/tag/${path_name}" ;;
|
|
*) url="${repo_url}" ;;
|
|
esac
|
|
if [[ "$first" -eq 1 ]]; then
|
|
first=0
|
|
else
|
|
printf ', '
|
|
fi
|
|
printf '[`%s`](%s)' "$name" "$url"
|
|
done <<<"$names"
|
|
}
|
|
|
|
branch_md="$(linkify_names branch "$branches")"
|
|
tag_md="$(linkify_names tag "$tags")"
|
|
|
|
# When no branch tip matches (historical SHA cuts), fall back to
|
|
# name-rev so the summary still shows something like `main~3`.
|
|
contains_md="_none_"
|
|
if [[ "$branch_md" == "_none_" ]]; then
|
|
approx="$(git name-rev --name-only --no-undefined --refs='refs/remotes/origin/*' "$sha" 2>/dev/null || true)"
|
|
if [[ -n "$approx" ]]; then
|
|
# name-rev prints remotes/origin/<branch>[~N]; strip to branch[~N].
|
|
approx="${approx#remotes/origin/}"
|
|
approx="${approx#origin/}"
|
|
contains_md="\`${approx}\`"
|
|
fi
|
|
fi
|
|
|
|
input_kind="ref"
|
|
if git rev-parse -q --verify "refs/remotes/origin/${input_ref}" >/dev/null 2>&1; then
|
|
input_kind="branch"
|
|
elif git rev-parse -q --verify "refs/tags/${input_ref}" >/dev/null 2>&1; then
|
|
input_kind="tag"
|
|
elif [[ "$input_ref" =~ ^[0-9a-fA-F]{7,40}$ ]]; then
|
|
input_kind="sha"
|
|
fi
|
|
|
|
{
|
|
echo "## Resolved source"
|
|
echo ""
|
|
echo "Every cut resolves to a commit. Branch/tag rows list refs whose tip is that commit."
|
|
echo ""
|
|
echo "| Field | Value |"
|
|
echo "| --- | --- |"
|
|
echo "| Input ref | \`${input_ref}\` (${input_kind}) |"
|
|
echo "| Commit | [\`${short_sha}\`](${repo_url}/commit/${sha}) |"
|
|
echo "| Branches at commit | ${branch_md} |"
|
|
echo "| Tags at commit | ${tag_md} |"
|
|
if [[ "$branch_md" == "_none_" ]]; then
|
|
echo "| Also on | ${contains_md} |"
|
|
fi
|
|
echo ""
|
|
} >> "$GITHUB_STEP_SUMMARY"
|
|
|
|
- name: Compute RC slot
|
|
id: slot
|
|
run: |
|
|
slot=$(TZ=America/Los_Angeles date '+%Y-%m-%d-%H')
|
|
echo "value=$slot" >>"$GITHUB_OUTPUT"
|
|
|
|
- name: Validate PT release window
|
|
id: window
|
|
env:
|
|
EVENT_NAME: ${{ github.event_name }}
|
|
run: |
|
|
if [[ "$EVENT_NAME" != "schedule" ]]; then
|
|
echo "allowed=true" >>"$GITHUB_OUTPUT"
|
|
echo "reason=manual" >>"$GITHUB_OUTPUT"
|
|
exit 0
|
|
fi
|
|
|
|
pt_hour=$(TZ=America/Los_Angeles date '+%H')
|
|
pt_minute=$(TZ=America/Los_Angeles date '+%M')
|
|
|
|
# Why: GitHub may deliver a scheduled event long after the intended
|
|
# time, so delayed 4:16 AM runs must not cut the 3:00 AM release.
|
|
if [[ "$pt_hour" == "03" || "$pt_hour" == "15" ]]; then
|
|
echo "allowed=true" >>"$GITHUB_OUTPUT"
|
|
echo "reason=target_hour:${pt_hour}:${pt_minute}" >>"$GITHUB_OUTPUT"
|
|
exit 0
|
|
fi
|
|
|
|
echo "allowed=false" >>"$GITHUB_OUTPUT"
|
|
echo "reason=outside_target_hour:${pt_hour}:${pt_minute}" >>"$GITHUB_OUTPUT"
|
|
|
|
- name: Skip if this PT release window already ran
|
|
id: existing
|
|
if: github.event_name == 'schedule' && steps.window.outputs.allowed == 'true'
|
|
run: |
|
|
# Why: scheduled runs retry inside each target hour, so make the
|
|
# schedule idempotent by embedding a slot marker in the release commit.
|
|
if git log origin/main --grep="\\[rc-slot:${{ steps.slot.outputs.value }}\\]" -n 1 --format=%H | grep -q .; then
|
|
echo "already_ran=true" >>"$GITHUB_OUTPUT"
|
|
exit 0
|
|
fi
|
|
|
|
# Why: this preserves dedupe across the older scheduled workflow's
|
|
# first runs, before all RC cuts shared release-cut's slot marker.
|
|
latest_rc_tag="$(git for-each-ref --sort=-creatordate --format='%(refname:short) %(creatordate:iso-strict)' 'refs/tags/v*-rc.*' | head -n 1)"
|
|
if [[ -n "$latest_rc_tag" ]]; then
|
|
latest_rc_tag_name="${latest_rc_tag%% *}"
|
|
latest_rc_tag_date="${latest_rc_tag#* }"
|
|
latest_rc_slot="$(TZ=America/Los_Angeles date -d "$latest_rc_tag_date" '+%Y-%m-%d-%H')"
|
|
|
|
if [[ "$latest_rc_slot" == "${{ steps.slot.outputs.value }}" ]]; then
|
|
echo "already_ran=true" >>"$GITHUB_OUTPUT"
|
|
echo "reason=latest_rc_tag:$latest_rc_tag_name" >>"$GITHUB_OUTPUT"
|
|
exit 0
|
|
fi
|
|
fi
|
|
|
|
echo "already_ran=false" >>"$GITHUB_OUTPUT"
|
|
|
|
- name: Dry run summary
|
|
if: github.event_name == 'workflow_dispatch' && inputs.dry_run
|
|
run: |
|
|
echo "Dry run only."
|
|
echo "Current PT slot: ${{ steps.slot.outputs.value }}"
|
|
echo "Window allowed: ${{ steps.window.outputs.allowed }}"
|
|
echo "Window reason: ${{ steps.window.outputs.reason }}"
|
|
echo "Already ran this slot: ${{ steps.existing.outputs.already_ran }}"
|
|
echo "Reason: ${{ steps.existing.outputs.reason }}"
|
|
|
|
- name: Skip summary
|
|
if: steps.window.outputs.allowed != 'true' || steps.existing.outputs.already_ran == 'true'
|
|
run: |
|
|
echo "Skipping release cut."
|
|
echo "Current PT slot: ${{ steps.slot.outputs.value }}"
|
|
echo "Window reason: ${{ steps.window.outputs.reason }}"
|
|
echo "Already ran this slot: ${{ steps.existing.outputs.already_ran }}"
|
|
echo "Reason: ${{ steps.existing.outputs.reason }}"
|
|
|
|
- name: Publish complete release-cut RC drafts from prior runs
|
|
id: publish_drafts
|
|
# Why: a manual RC dispatch should unstick any complete RC draft before
|
|
# deciding whether to cut another tag.
|
|
if: steps.window.outputs.allowed == 'true' && !(github.event_name == 'workflow_dispatch' && inputs.dry_run)
|
|
env:
|
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
run: node config/scripts/publish-complete-draft-releases.mjs
|
|
|
|
- name: Compute next version
|
|
id: version
|
|
# Why: if an RC run only had complete drafts to publish, stop there
|
|
# instead of immediately cutting another RC after the recovered one.
|
|
# Stable dispatches should still cut the requested stable release.
|
|
if: steps.window.outputs.allowed == 'true' && steps.existing.outputs.already_ran != 'true' && !(github.event_name == 'workflow_dispatch' && inputs.dry_run) && !((github.event_name == 'schedule' || inputs.kind == 'rc') && steps.publish_drafts.outputs.published_count != '0' && steps.publish_drafts.outputs.skipped_count == '0')
|
|
env:
|
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
KIND: ${{ github.event_name == 'schedule' && 'rc' || inputs.kind }}
|
|
VERSION_SUFFIX: ${{ github.event_name == 'schedule' && '' || inputs.version_suffix }}
|
|
EXPLICIT_VERSION: ${{ github.event_name == 'schedule' && '' || inputs.version }}
|
|
run: |
|
|
set -euo pipefail
|
|
|
|
# Latest stable release tag, picked by *tag shape* and semver max:
|
|
# - must start with `v<digit>` (desktop convention, e.g. v1.3.32)
|
|
# - must NOT contain `-rc.` (not a prerelease)
|
|
#
|
|
# Why not the GitHub `isPrerelease` flag: electron-builder's publish
|
|
# step has flipped that flag back to `false` on RC releases before
|
|
# (v1.3.22-rc.2 on 2026-04-27 briefly became "latest" on GitHub and
|
|
# poisoned the math here). Tag format is authoritative.
|
|
#
|
|
# Why the `^v[0-9]` prefix: other products shipped from this repo
|
|
# use their own prefixes (e.g. `mobile-v0.0.1`). Without the prefix
|
|
# gate the latest mobile release would be selected as "latest
|
|
# stable", then `strip_pre()` would reduce `mobile-v0.0.1` to
|
|
# `mobile`, `Number("mobile")` → NaN → 0, and a patch-bump would
|
|
# produce `0.0.1` — exactly the wedge on 2026-05-04 (run
|
|
# 25304336767). Any non-desktop tag shape must be excluded here.
|
|
#
|
|
# Why not `gh release list` order: GitHub can list a newer published
|
|
# stable after older releases. On 2026-06-04, v1.4.44 existed but
|
|
# the list returned v1.4.42 first, causing a manual RC cut to reopen
|
|
# the already-shipped 1.4.43 series as v1.4.43-rc.0.
|
|
latest_stable="$(node config/scripts/latest-stable-release.mjs)"
|
|
latest_stable="${latest_stable#v}"
|
|
echo "Latest stable: ${latest_stable:-<none>}"
|
|
|
|
# Strip any prerelease suffix before numeric math. Without this,
|
|
# `Number("1-rc")` returns NaN and `(NaN||0)+1` silently collapses
|
|
# to 1 — exactly the path that produced v1.3.1-rc.4 on 2026-04-27
|
|
# when latest_stable was misread as a prerelease tag.
|
|
strip_pre() { echo "${1%%-*}"; }
|
|
|
|
semver_gt() {
|
|
# returns 0 if $1 > $2 by semver rules (ignoring prerelease)
|
|
node -e '
|
|
const a = process.argv[1].split(".").map(Number);
|
|
const b = process.argv[2].split(".").map(Number);
|
|
for (let i = 0; i < 3; i++) {
|
|
if ((a[i]||0) > (b[i]||0)) process.exit(0);
|
|
if ((a[i]||0) < (b[i]||0)) process.exit(1);
|
|
}
|
|
process.exit(1);
|
|
' "$(strip_pre "$1")" "$(strip_pre "$2")"
|
|
}
|
|
|
|
bump() {
|
|
# $1=version, $2=level (patch|minor|major)
|
|
node -e '
|
|
const v = process.argv[1].split(".").map(Number);
|
|
const level = process.argv[2];
|
|
if (level === "major") console.log(`${(v[0]||0)+1}.0.0`);
|
|
else if (level === "minor") console.log(`${v[0]||0}.${(v[1]||0)+1}.0`);
|
|
else console.log(`${v[0]||0}.${v[1]||0}.${(v[2]||0)+1}`);
|
|
' "$(strip_pre "$1")" "$2"
|
|
}
|
|
|
|
highest_rc_for_base() {
|
|
node config/scripts/release-rc-history.mjs "$1"
|
|
}
|
|
|
|
require_valid_version_suffix() {
|
|
# Why a dot-appended identifier (rc.N.perf): it sorts just
|
|
# above its own base rc.N but BELOW rc.N+1, so suffixed side-
|
|
# branch builds never outrank the main RC series and cannot
|
|
# hijack the update channel; clients find them by matching the
|
|
# identifier ("perf") in the prerelease components.
|
|
# Why the numeric alternation rather than plain [0-9A-Za-z]+:
|
|
# semver forbids a leading zero on an all-digit identifier, and
|
|
# `npm version` silently renormalizes rc.4.01 to rc.4.1 while the
|
|
# tag step keeps the literal input — so the shipped package.json
|
|
# version and its own release tag would name different releases.
|
|
if [[ ! "$1" =~ ^(0|[1-9][0-9]*|[0-9A-Za-z]*[A-Za-z][0-9A-Za-z]*)$ ]]; then
|
|
echo "::error::version_suffix (or the trailing .identifier in version) must be alphanumeric with no leading zero on an all-digit identifier, got: $1" >&2
|
|
exit 1
|
|
fi
|
|
}
|
|
|
|
current_package_stable() {
|
|
node -e '
|
|
const { version } = require("./package.json");
|
|
if (/^[0-9]+\.[0-9]+\.[0-9]+$/.test(version)) console.log(version);
|
|
'
|
|
}
|
|
|
|
tag_matches_current_ref() {
|
|
local tag="$1"
|
|
local tag_commit
|
|
local head_commit
|
|
if ! tag_commit="$(git rev-parse "${tag}^{}" 2>/dev/null)"; then
|
|
return 1
|
|
fi
|
|
head_commit="$(git rev-parse HEAD)"
|
|
if [[ "$tag_commit" == "$head_commit" ]]; then
|
|
return 0
|
|
fi
|
|
|
|
local tag_parent
|
|
tag_parent="$(git rev-parse "${tag_commit}^" 2>/dev/null)" || return 1
|
|
[[ "$tag_parent" == "$head_commit" ]]
|
|
}
|
|
|
|
release_draft_state() {
|
|
# Prints: true, false, or missing.
|
|
local tag="$1"
|
|
local state_file="$RUNNER_TEMP/release-state-${tag//[^A-Za-z0-9_.-]/_}"
|
|
if gh release view "$tag" \
|
|
--repo "$GITHUB_REPOSITORY" \
|
|
--json isDraft \
|
|
--jq '.isDraft' >"$state_file" 2>/dev/null; then
|
|
cat "$state_file"
|
|
else
|
|
echo "missing"
|
|
fi
|
|
}
|
|
|
|
recover_unpublished_tag() {
|
|
local tag="$1"
|
|
local reason="$2"
|
|
local release_state
|
|
release_state="$(release_draft_state "$tag")"
|
|
case "$release_state" in
|
|
missing|true)
|
|
if ! tag_matches_current_ref "$tag"; then
|
|
echo "::warning::Tag $tag already exists but was cut from a different release ref ($reason) - cutting the next version instead of reusing stale artifacts."
|
|
return 1
|
|
fi
|
|
echo "::warning::Tag $tag already exists but has no published release ($reason) - recovering by re-dispatching the release build against the existing tag."
|
|
echo "recovered_tag=$tag" >>"$GITHUB_OUTPUT"
|
|
echo "recovered=true" >>"$GITHUB_OUTPUT"
|
|
exit 0
|
|
;;
|
|
false)
|
|
return 1
|
|
;;
|
|
*)
|
|
echo "::error::Unexpected release state for $tag: $release_state" >&2
|
|
exit 1
|
|
;;
|
|
esac
|
|
}
|
|
|
|
# Fresh repo fallback so the math below never divides by zero.
|
|
if [[ -z "$latest_stable" ]]; then
|
|
latest_stable="0.0.0"
|
|
fi
|
|
|
|
package_stable="$(current_package_stable)"
|
|
if [[ -n "$package_stable" ]]; then
|
|
# Why: if a stable release is deleted after its version-bump commit
|
|
# reached main, GitHub's release list regresses. package.json is the
|
|
# floor for the current ref so the next cut cannot reuse an older
|
|
# stable number just because the public release was nuked.
|
|
if semver_gt "$package_stable" "$latest_stable"; then
|
|
# Skip floor-tag recovery when an explicit version is requested:
|
|
# recover_unpublished_tag can exit 0, which would recover the
|
|
# package-floor tag instead of cutting the requested version —
|
|
# defeating the very rollback scenario the override exists for.
|
|
# We still raise latest_stable to the floor below so the explicit
|
|
# version is gated against it; the collision recovery for the
|
|
# requested tag runs later.
|
|
if [[ "$KIND" != "rc" && -z "${EXPLICIT_VERSION:-}" ]]; then
|
|
package_tag="v$package_stable"
|
|
if git rev-parse "$package_tag" >/dev/null 2>&1; then
|
|
recover_unpublished_tag "$package_tag" "current ref stable tag is newer than latest published stable" || true
|
|
fi
|
|
fi
|
|
|
|
echo "Stable floor from package.json: $package_stable"
|
|
latest_stable="$package_stable"
|
|
fi
|
|
fi
|
|
|
|
# Explicit version override (manual dispatch only).
|
|
#
|
|
# Why: kind-based math derives the next number from the latest
|
|
# *published* stable. When a shipped stable is deleted (e.g. a
|
|
# rolled-back 1.4.154), the release list regresses to the prior
|
|
# stable, so a kind cut recomputes a number at or below the nuked one
|
|
# and strands every client that already installed the deleted build.
|
|
# The package.json floor above only recovers this when the deleted
|
|
# version's bump commit is on the ref being cut, which a hotfix cut
|
|
# from an older RC ref does not carry. An explicit version lets a
|
|
# human assert the exact target (e.g. leapfrog to 1.4.155); the
|
|
# updater-safety gate and tag-collision recovery below still apply.
|
|
new=""
|
|
if [[ -n "${EXPLICIT_VERSION:-}" ]]; then
|
|
explicit="${EXPLICIT_VERSION#v}"
|
|
# Why the optional trailing identifier: it lets an operator name a
|
|
# suffixed side-branch RC (X.Y.Z-rc.N.perf) directly, the same shape
|
|
# the rc path cuts. Note this only ever admits one *above* the
|
|
# series head — the gate below refuses a suffixed rc at or below it
|
|
# just like a bare one, so this is a second spelling of
|
|
# `version=X.Y.Z-rc.N` + `version_suffix`, not a way back into a
|
|
# series that already shipped.
|
|
# Why rc.(0|[1-9][0-9]{0,8}): the `-le` below compares with bash's
|
|
# machine-width integers, so both ends of that range fall *open* on
|
|
# exactly the RCs this gate must catch. A leading zero (rc.08) is an
|
|
# invalid octal literal, and the failed test makes the `if` false.
|
|
# Past INTMAX the literal wraps two's-complement, so whether it
|
|
# reads as above or below the published rc depends on the value:
|
|
# rc.99999999999999999999 wraps to 7766279631452241919 and sails
|
|
# through. The cut then lands a tag that pins highest_rc_for_base
|
|
# at 1e20 forever, and every later cut wraps to a *lower* rc that
|
|
# sorts below it, so the fleet never updates again. Nine digits is
|
|
# far above any real series and exact in bash math either way.
|
|
if [[ ! "$explicit" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-rc\.(0|[1-9][0-9]{0,8})(\.[0-9A-Za-z]+)?)?$ ]]; then
|
|
echo "::error::version must be X.Y.Z, X.Y.Z-rc.N, or X.Y.Z-rc.N.suffix, got: $EXPLICIT_VERSION" >&2
|
|
exit 1
|
|
fi
|
|
# Why route the embedded identifier through the same validator the
|
|
# kind path uses: the regex above only checks shape, and rc.4.01
|
|
# is a shape-valid identifier that is not valid semver.
|
|
if [[ "$explicit" == *-rc.*.* ]]; then
|
|
require_valid_version_suffix "${explicit##*.}"
|
|
fi
|
|
# Same updater-safety gate the kind path enforces: stable line must
|
|
# strictly increase over the latest published stable (prerelease
|
|
# identifiers ignored for the comparison).
|
|
if ! semver_gt "$explicit" "$latest_stable"; then
|
|
echo "::error::Refusing explicit version $explicit: not greater than latest stable $latest_stable." >&2
|
|
exit 1
|
|
fi
|
|
# Why a second gate for prereleases: semver_gt compares through
|
|
# strip_pre(), so the stable-line check reads 1.4.156-rc.0 as
|
|
# 1.4.156 and waves it past a 1.4.155 stable even when rc.0..rc.3
|
|
# already shipped — republishing an RC *below* what clients run,
|
|
# the same regression class as the rc.4 cut that orphaned live
|
|
# daemons. Anchor on the same rc history the kind path uses so the
|
|
# override can only ever advance the series it targets.
|
|
if [[ "$explicit" == *-rc.* ]]; then
|
|
explicit_base="${explicit%%-*}"
|
|
explicit_rc="${explicit#*-rc.}"
|
|
explicit_rc="${explicit_rc%%.*}"
|
|
highest_explicit_rc="$(highest_rc_for_base "$explicit_base")"
|
|
if [[ -n "$highest_explicit_rc" && "$explicit_rc" -le "$highest_explicit_rc" ]]; then
|
|
# Why the remedy is spelled this narrowly: kind=rc derives its
|
|
# base from bump(latest_stable, patch), so it can only resume a
|
|
# series on that base. A minor/major series (1.5.0-rc.N) exists
|
|
# only because this override created it, and pointing an
|
|
# operator at kind=rc there would cut an unrelated release.
|
|
echo "::error::Refusing explicit version $explicit: rc.$explicit_rc is not above rc.$highest_explicit_rc, the highest already cut for $explicit_base. Request rc.$((highest_explicit_rc + 1)) or higher. If you are resuming an unpublished tag and $explicit_base is the next patch after latest stable $latest_stable, dispatch kind=rc instead, which recovers that tag when it was cut from the ref you dispatch; otherwise cut rc.$((highest_explicit_rc + 1)) and leave the unpublished tag alone." >&2
|
|
exit 1
|
|
fi
|
|
fi
|
|
new="$explicit"
|
|
# Why here too: the suffix append below lives in the kind path the
|
|
# override skips, so an operator passing both inputs used to get
|
|
# their suffix silently dropped. Only a bare rc can take one — a
|
|
# stable X.Y.Z.perf is not valid semver, and re-suffixing an
|
|
# already-suffixed rc would produce rc.N.perf.perf.
|
|
if [[ -n "${VERSION_SUFFIX:-}" ]]; then
|
|
# Same bounded rc pattern as the shape check above, so the two
|
|
# cannot drift apart under a later edit.
|
|
if [[ ! "$explicit" =~ ^[0-9]+\.[0-9]+\.[0-9]+-rc\.(0|[1-9][0-9]{0,8})$ ]]; then
|
|
echo "::error::version_suffix applies only to a bare X.Y.Z-rc.N version, got: $explicit" >&2
|
|
exit 1
|
|
fi
|
|
require_valid_version_suffix "$VERSION_SUFFIX"
|
|
new="${new}.${VERSION_SUFFIX}"
|
|
fi
|
|
echo "Explicit version override: $new"
|
|
fi
|
|
|
|
if [[ -z "$new" ]]; then
|
|
case "$KIND" in
|
|
rc)
|
|
# Why: RCs always stabilize the *next* patch after whatever
|
|
# is currently published as stable. Earlier logic tried to
|
|
# "continue the current series" by reading the highest git
|
|
# tag, which silently reopened a series that had already
|
|
# shipped (e.g. cutting v1.3.21-rc.7 after v1.3.21 stable
|
|
# was out). Anchoring to latest_stable + patch eliminates
|
|
# that class of bug; minor/major RCs are cut by running
|
|
# that stable kind first.
|
|
base="$(bump "$latest_stable" patch)"
|
|
highest_rc="$(highest_rc_for_base "$base")"
|
|
if [[ -z "$highest_rc" ]]; then
|
|
new="${base}-rc.0"
|
|
else
|
|
existing_rc_tag="v${base}-rc.${highest_rc}"
|
|
# Why: a failed or GitHub-stuck run can leave the highest RC
|
|
# tag attached to a draft/missing release. Resume only when it
|
|
# was cut from this ref; stale attempts advance to rc.N+1.
|
|
if git rev-parse "$existing_rc_tag" >/dev/null 2>&1; then
|
|
recover_unpublished_tag "$existing_rc_tag" "latest RC in series" || true
|
|
fi
|
|
new="${base}-rc.$((highest_rc + 1))"
|
|
fi
|
|
if [[ -n "${VERSION_SUFFIX:-}" ]]; then
|
|
require_valid_version_suffix "$VERSION_SUFFIX"
|
|
new="${new}.${VERSION_SUFFIX}"
|
|
fi
|
|
;;
|
|
patch|minor|major)
|
|
new="$(bump "$latest_stable" "$KIND")"
|
|
# Updater-safety gate: stable must strictly increase.
|
|
if ! semver_gt "$new" "$latest_stable"; then
|
|
echo "::error::Refusing to cut $KIND $new: not greater than latest stable $latest_stable." >&2
|
|
exit 1
|
|
fi
|
|
|
|
# Why: a stale orphan stable tag can exist from an older release
|
|
# ref after main has moved on. If it cannot be recovered for the
|
|
# current ref, advance to the next stable version instead of
|
|
# wedging every future patch cut on the same collision.
|
|
for _ in {1..100}; do
|
|
candidate_tag="v$new"
|
|
if ! git rev-parse "$candidate_tag" >/dev/null 2>&1; then
|
|
break
|
|
fi
|
|
|
|
candidate_release_state="$(release_draft_state "$candidate_tag")"
|
|
case "$candidate_release_state" in
|
|
missing|true)
|
|
recover_unpublished_tag "$candidate_tag" "tag collision" || true
|
|
new="$(bump "$new" "$KIND")"
|
|
;;
|
|
false)
|
|
echo "::error::Tag $candidate_tag already exists with a published release. Refusing to skip over a shipped version." >&2
|
|
exit 1
|
|
;;
|
|
*)
|
|
echo "::error::Unexpected release state for $candidate_tag: $candidate_release_state" >&2
|
|
exit 1
|
|
;;
|
|
esac
|
|
done
|
|
;;
|
|
*)
|
|
echo "::error::Unknown kind: $KIND" >&2
|
|
exit 1
|
|
;;
|
|
esac
|
|
fi
|
|
|
|
# Orphan-tag recovery.
|
|
#
|
|
# Why: if a previous cut pushed the tag but was cancelled (or the
|
|
# dependent release build jobs otherwise failed to start) before the
|
|
# GitHub Release was published, the tag now exists on the remote
|
|
# but "latest stable" still points at the prior version. Every
|
|
# subsequent patch cut then recomputes the same version and dies
|
|
# on "Tag already exists." This exact sequence wedged the cut
|
|
# pipeline on 2026-05-01 when v1.3.26 was pushed by a cancelled
|
|
# run (25237882049) — every patch cut after that rehit the same
|
|
# tag for hours until the orphan release was dispatched by hand.
|
|
#
|
|
# Recovery policy: if the tag exists AND no GitHub release has
|
|
# been published for it (draft-or-absent both count as "not
|
|
# shipped"), treat this as a resumable state: emit the existing
|
|
# tag as the job output so the downstream release build jobs run
|
|
# against it and finishes what the earlier attempt started. The
|
|
# bump/commit/push steps are skipped in that case — there is
|
|
# nothing to bump; the tag is already on the remote.
|
|
#
|
|
# Refuse collisions only when the tag *and* a published release
|
|
# already exist — that's a real conflict (someone tagged manually
|
|
# over a shipped version) and needs human attention.
|
|
if git rev-parse "v$new" >/dev/null 2>&1; then
|
|
recover_unpublished_tag "v$new" "tag collision" || {
|
|
echo "::error::Tag v$new already exists and cannot be recovered for this ref. Refusing to re-cut over an existing version." >&2
|
|
exit 1
|
|
}
|
|
fi
|
|
|
|
echo "version=$new" >>"$GITHUB_OUTPUT"
|
|
echo "Next version: $new"
|
|
|
|
- name: Bump package.json and tag
|
|
id: tag
|
|
if: steps.version.outputs.version != '' && steps.version.outputs.recovered != 'true'
|
|
env:
|
|
EVENT_NAME: ${{ github.event_name }}
|
|
SLOT: ${{ steps.slot.outputs.value }}
|
|
VERSION: ${{ steps.version.outputs.version }}
|
|
run: |
|
|
set -euo pipefail
|
|
# Why: use npm version --no-git-tag-version so we control the commit
|
|
# message and tag name explicitly (avoids npm's `v1.2.3` prefix
|
|
# assumptions and any lifecycle scripts that would run on bump).
|
|
npm version "$VERSION" --no-git-tag-version --allow-same-version
|
|
# Why: freshness compares installs against the bundled manifest, so a tag must
|
|
# never ship skill bytes it doesn't describe. Node built-ins only; no install.
|
|
if ! node config/scripts/generate-skill-bundle-manifest.mjs; then
|
|
echo "::error::Refusing to tag v$VERSION: the committed skill manifest does not match skills/. Land a regeneration on main, then re-run the cut." >&2
|
|
exit 1
|
|
fi
|
|
git add package.json
|
|
commit_message="release: v$VERSION"
|
|
if [[ "$EVENT_NAME" == "schedule" ]]; then
|
|
commit_message="$commit_message [rc-slot:$SLOT]"
|
|
fi
|
|
if git diff --cached --quiet; then
|
|
# Why: a failed cut can push the version bump to main before the
|
|
# release is published. Re-cutting then needs a fresh taggable
|
|
# release commit even though package.json is already at VERSION.
|
|
git commit --allow-empty -m "$commit_message"
|
|
else
|
|
git commit -m "$commit_message"
|
|
fi
|
|
# Why: a lint that greps this file cannot see a path built from an env
|
|
# var, a composite action, or concatenation, and `git commit` has forms
|
|
# (-a, -i, --only, a pathspec) that commit the working tree rather than
|
|
# the index. Assert what the commit actually carries, so the tag can
|
|
# only ever ship the version bump, no matter which step staged what or
|
|
# how the commit was spelled.
|
|
# -F because the allowlist is literal: unanchored, `.` would match any
|
|
# character and quietly admit a path like `packageXjson`.
|
|
# -m --first-parent: plain diff-tree prints NOTHING for a merge commit,
|
|
# which would make this guard pass silently rather than fail closed.
|
|
committed="$(git diff-tree --no-commit-id --name-only -r -m --first-parent HEAD |
|
|
grep -vxF -e 'package.json' || true)"
|
|
if [[ -n "$committed" ]]; then
|
|
echo "::error::Release commit carries unexpected paths: $(echo "$committed" | tr '\n' ' ')Only package.json may ship in a version commit." >&2
|
|
exit 1
|
|
fi
|
|
git tag -a "v$VERSION" -m "v$VERSION"
|
|
echo "tag=v$VERSION" >>"$GITHUB_OUTPUT"
|
|
echo "sha=$(git rev-parse HEAD)" >>"$GITHUB_OUTPUT"
|
|
|
|
- name: Push tag
|
|
if: steps.tag.outputs.tag != ''
|
|
env:
|
|
PUSH_MAIN: ${{ steps.resolve.outputs.push_main }}
|
|
TAG: ${{ steps.tag.outputs.tag }}
|
|
run: |
|
|
set -euo pipefail
|
|
if [[ "$PUSH_MAIN" == "true" ]]; then
|
|
push_log="$RUNNER_TEMP/release-push-main.log"
|
|
if git push origin "HEAD:refs/heads/main" >"$push_log" 2>&1; then
|
|
cat "$push_log"
|
|
elif grep -Fq 'GH013' "$push_log" && grep -Fq 'Changes must be made through a pull request.' "$push_log"; then
|
|
cat "$push_log"
|
|
echo "::notice::Main requires pull requests; leaving its version unchanged and publishing only $TAG."
|
|
else
|
|
cat "$push_log"
|
|
exit 1
|
|
fi
|
|
fi
|
|
git push origin "$TAG"
|
|
|
|
# Why: a patch cut from a base older than #24155 has no orcad template to build or ship.
|
|
- name: Detect whether the tag ships the orcad template
|
|
id: orcad-template-support
|
|
if: steps.tag.outputs.tag != '' || steps.version.outputs.recovered_tag != ''
|
|
env:
|
|
TAG: ${{ steps.tag.outputs.tag || steps.version.outputs.recovered_tag }}
|
|
run: |
|
|
set -euo pipefail
|
|
if git cat-file -e "refs/tags/${TAG}^{commit}:config/scripts/packaged-orcad-template.cjs" 2>/dev/null; then
|
|
echo "ships=true" >>"$GITHUB_OUTPUT"
|
|
else
|
|
echo "ships=false" >>"$GITHUB_OUTPUT"
|
|
echo "::notice::$TAG predates the orcad template; skipping its build and download."
|
|
fi
|
|
|
|
- name: Release E2E signal summary
|
|
if: always()
|
|
run: |
|
|
{
|
|
echo "## Release E2E Signal"
|
|
echo ""
|
|
echo "- Platform golden E2E is release-blocking: terminal rendering, restrictive-umask profile writes, source control, and agent TUI launch on Linux/macOS, plus fresh startup and source control on Windows."
|
|
echo "- Exception: every golden except terminal rendering runs with \`--if-present\`, so it is skipped (not failed) on older tags that predate its script."
|
|
echo "- Full E2E runs separately after publication and cannot change the release result."
|
|
echo "- Terminal rendering release evidence is diagnostic/non-blocking."
|
|
echo ""
|
|
echo "Publishing behavior is controlled by the existing job dependencies; this summary does not change release gating."
|
|
} >> "$GITHUB_STEP_SUMMARY"
|
|
|
|
create-release:
|
|
needs: cut
|
|
if: needs.cut.outputs.should_release == 'true'
|
|
runs-on: ubuntu-latest
|
|
permissions:
|
|
contents: write
|
|
steps:
|
|
- name: Checkout
|
|
uses: actions/checkout@v6
|
|
with:
|
|
ref: refs/tags/${{ needs.cut.outputs.tag }}
|
|
|
|
- name: Restore draft-release scripts from the workflow ref
|
|
env:
|
|
WORKFLOW_SHA: ${{ github.workflow_sha }}
|
|
run: |
|
|
set -euo pipefail
|
|
git fetch --no-tags --depth=1 origin "$WORKFLOW_SHA"
|
|
git checkout "$WORKFLOW_SHA" -- \
|
|
config/scripts/create-draft-release.mjs \
|
|
config/scripts/assert-github-release-is-draft.mjs
|
|
|
|
- name: Create draft release with bounded generated notes
|
|
env:
|
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
TAG: ${{ needs.cut.outputs.tag }}
|
|
run: node config/scripts/create-draft-release.mjs "$TAG"
|
|
|
|
dependency-security:
|
|
needs: cut
|
|
if: needs.cut.outputs.should_release == 'true' && !contains(needs.cut.outputs.tag, '-')
|
|
uses: ./.github/workflows/dependency-security.yml
|
|
with:
|
|
ref: refs/tags/${{ needs.cut.outputs.tag }}
|
|
|
|
terminal-rendering-golden:
|
|
needs: cut
|
|
if: needs.cut.outputs.should_release == 'true'
|
|
name: golden e2e ${{ matrix.platform }}
|
|
runs-on: ${{ matrix.os }}
|
|
timeout-minutes: 30
|
|
env:
|
|
NODE_OPTIONS: --max-old-space-size=4096
|
|
strategy:
|
|
fail-fast: false
|
|
matrix:
|
|
include:
|
|
- os: ubuntu-latest
|
|
platform: linux
|
|
- os: macos-15
|
|
platform: mac
|
|
# Windows terminal rendering remains flaky; keep its blocking signal
|
|
# scoped to the fresh-profile startup regression from #14130.
|
|
- os: windows-2022
|
|
platform: windows
|
|
|
|
steps:
|
|
- name: Checkout
|
|
uses: actions/checkout@v6
|
|
with:
|
|
ref: refs/tags/${{ needs.cut.outputs.tag }}
|
|
|
|
- name: Restore golden test harness from the workflow ref
|
|
shell: bash
|
|
env:
|
|
WORKFLOW_SHA: ${{ github.workflow_sha }}
|
|
run: |
|
|
git fetch --no-tags --depth=1 origin "$WORKFLOW_SHA"
|
|
git checkout "$WORKFLOW_SHA" -- \
|
|
tests/e2e/golden-source-control-open-diff.spec.ts \
|
|
tests/e2e/golden-terminal-file-link.spec.ts \
|
|
tests/e2e/golden-worktree-create-switch.spec.ts
|
|
|
|
- name: Install native build tools
|
|
if: runner.os == 'Linux'
|
|
run: sudo apt-get update && sudo apt-get install -y build-essential python3 xvfb
|
|
|
|
- name: Setup pnpm
|
|
uses: pnpm/setup@v2
|
|
with:
|
|
install: false
|
|
|
|
- name: Setup Node.js
|
|
uses: actions/setup-node@v6
|
|
with:
|
|
node-version-file: package.json
|
|
check-latest: true
|
|
cache: pnpm
|
|
|
|
# Why: Linux terminal golden E2E uses the same native install path as
|
|
# release CI, which needs pnpm to bypass its non-executable gyp_main.py.
|
|
- name: Use external node-gyp to avoid pnpm's bundled copy (Linux only)
|
|
if: runner.os == 'Linux'
|
|
run: |
|
|
npm install -g node-gyp@11.5.0
|
|
echo "npm_config_node_gyp=$(npm root -g)/node-gyp/bin/node-gyp.js" >> "$GITHUB_ENV"
|
|
|
|
# Why: this install runs lifecycle scripts, so node-gyp rebuilds
|
|
# native/windows-registry and fetches that Node version's headers from
|
|
# nodejs.org. One `read ECONNRESET` there failed this blocking gate and the
|
|
# whole cut. Retry like the release build's install below.
|
|
- name: Install dependencies
|
|
uses: nick-fields/retry@v4
|
|
with:
|
|
timeout_minutes: 10
|
|
max_attempts: 3
|
|
retry_wait_seconds: 30
|
|
command: pnpm install --frozen-lockfile
|
|
|
|
- name: Build Electron app for platform golden
|
|
run: npx electron-vite build --mode e2e
|
|
|
|
# Why: this job is defined on the dispatch ref (usually main) but checks
|
|
# out the release tag. Cherry-pick / hotfix tags can predate a golden
|
|
# script that main already calls; --if-present keeps those cuts green
|
|
# instead of failing with ERR_PNPM_NO_SCRIPT.
|
|
- name: Run terminal rendering golden on Linux
|
|
if: runner.os == 'Linux'
|
|
run: |
|
|
xvfb-run --auto-servernum env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run --if-present test:e2e:workspace-session-golden
|
|
xvfb-run --auto-servernum env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run test:e2e:terminal-rendering-golden
|
|
xvfb-run --auto-servernum env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run --if-present test:e2e:posix-profile-index-golden
|
|
|
|
- name: Run source-control golden on Linux
|
|
if: runner.os == 'Linux'
|
|
run: xvfb-run --auto-servernum env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run --if-present test:e2e:source-control-golden
|
|
|
|
- name: Run terminal rendering golden on macOS
|
|
if: runner.os == 'macOS'
|
|
run: |
|
|
env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run --if-present test:e2e:workspace-session-golden
|
|
env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run test:e2e:terminal-rendering-golden
|
|
env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run --if-present test:e2e:posix-profile-index-golden
|
|
|
|
- name: Run agent TUI golden on Linux
|
|
if: runner.os == 'Linux'
|
|
run: xvfb-run --auto-servernum env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run --if-present test:e2e:agent-tui-golden
|
|
|
|
- name: Run agent TUI golden on macOS
|
|
if: runner.os == 'macOS'
|
|
run: env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run --if-present test:e2e:agent-tui-golden
|
|
|
|
- name: Run source-control golden on macOS
|
|
if: runner.os == 'macOS'
|
|
run: env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run --if-present test:e2e:source-control-golden
|
|
|
|
- name: Run fresh-startup golden on Windows
|
|
if: runner.os == 'Windows'
|
|
shell: pwsh
|
|
run: |
|
|
$env:SKIP_BUILD = '1'
|
|
$env:ORCA_E2E_FORWARD_APP_LOGS = '1'
|
|
pnpm run --if-present test:e2e:windows-fresh-startup-golden
|
|
|
|
- name: Upload Playwright traces
|
|
if: failure()
|
|
uses: actions/upload-artifact@v7
|
|
with:
|
|
name: golden-e2e-${{ matrix.platform }}-playwright-traces
|
|
path: test-results/
|
|
retention-days: 7
|
|
if-no-files-found: ignore
|
|
|
|
skill-sharing-release-gate:
|
|
needs: cut
|
|
if: needs.cut.outputs.should_release == 'true'
|
|
name: skill sharing release gate ${{ matrix.platform }}
|
|
runs-on: ${{ matrix.os }}
|
|
# The full suite is release-blocking on macOS. Windows still produces the
|
|
# same evidence, but intermittent filesystem contention cannot block signing.
|
|
continue-on-error: ${{ matrix.platform == 'windows' }}
|
|
timeout-minutes: 20
|
|
strategy:
|
|
fail-fast: false
|
|
matrix:
|
|
include:
|
|
- os: macos-15
|
|
platform: mac
|
|
- os: windows-2022
|
|
platform: windows
|
|
|
|
steps:
|
|
- name: Checkout
|
|
uses: actions/checkout@v6
|
|
with:
|
|
ref: refs/tags/${{ needs.cut.outputs.tag }}
|
|
|
|
- name: Restore skill-sharing test harness from the workflow ref
|
|
shell: bash
|
|
env:
|
|
WORKFLOW_SHA: ${{ github.workflow_sha }}
|
|
run: |
|
|
git fetch --no-tags --depth=1 origin "$WORKFLOW_SHA"
|
|
git checkout "$WORKFLOW_SHA" -- \
|
|
src/main/skills/skill-provider-runtime-roots.test.ts
|
|
|
|
- uses: ./.github/actions/install-node-dependencies
|
|
with:
|
|
native-runtime: node
|
|
|
|
- name: Install Electron package binary for tests
|
|
run: node config/scripts/install-electron-package-binary.mjs
|
|
|
|
- name: Run skill package, transaction, and compatibility suites
|
|
env:
|
|
ORCA_REAL_PROCESS_SKILL_TEST: '1'
|
|
ORCA_REAL_WINDOWS_SKILL_TEST: ${{ runner.os == 'Windows' && '1' || '0' }}
|
|
run: pnpm test:skill-sharing:release --reporter=json --outputFile=skill-sharing-release-results.json
|
|
|
|
- name: Archive bounded skill-sharing results
|
|
if: always()
|
|
uses: actions/upload-artifact@v7
|
|
with:
|
|
name: skill-sharing-release-${{ matrix.platform }}
|
|
path: skill-sharing-release-results.json
|
|
retention-days: 14
|
|
if-no-files-found: error
|
|
|
|
skill-sharing-linux-floor-release-gate:
|
|
needs: cut
|
|
if: needs.cut.outputs.should_release == 'true'
|
|
name: skill sharing release gate linux-glibc-2.31
|
|
runs-on: ubuntu-latest
|
|
timeout-minutes: 20
|
|
container: ubuntu:20.04
|
|
|
|
steps:
|
|
- name: Install Ubuntu 20.04 prerequisites
|
|
run: apt-get update && apt-get install -y build-essential ca-certificates git python3 unzip
|
|
|
|
- name: Checkout
|
|
uses: actions/checkout@v6
|
|
with:
|
|
ref: refs/tags/${{ needs.cut.outputs.tag }}
|
|
|
|
- name: Trust the checked-out workspace in the job container
|
|
run: git config --global --add safe.directory "$GITHUB_WORKSPACE"
|
|
|
|
- name: Restore skill-sharing test harness from the workflow ref
|
|
shell: bash
|
|
env:
|
|
WORKFLOW_SHA: ${{ github.workflow_sha }}
|
|
run: |
|
|
git fetch --no-tags --depth=1 origin "$WORKFLOW_SHA"
|
|
git checkout "$WORKFLOW_SHA" -- \
|
|
src/main/skills/skill-provider-runtime-roots.test.ts
|
|
|
|
- uses: ./.github/actions/install-node-dependencies
|
|
with:
|
|
native-runtime: node
|
|
|
|
- name: Install Electron package binary for tests
|
|
run: node config/scripts/install-electron-package-binary.mjs
|
|
|
|
- name: Run skill package, transaction, and compatibility suites
|
|
env:
|
|
ORCA_REAL_PROCESS_SKILL_TEST: '1'
|
|
run: pnpm test:skill-sharing:release --reporter=json --outputFile=skill-sharing-release-results.json
|
|
|
|
- name: Archive bounded skill-sharing results
|
|
if: always()
|
|
uses: actions/upload-artifact@v7
|
|
with:
|
|
name: skill-sharing-release-linux-glibc-2.31
|
|
path: skill-sharing-release-results.json
|
|
retention-days: 14
|
|
if-no-files-found: error
|
|
|
|
# Why: these broader terminal rendering repros are useful release evidence,
|
|
# but they include heavier app-like flows and must not block publishing.
|
|
terminal-rendering-release-evidence:
|
|
needs: cut
|
|
if: needs.cut.outputs.should_release == 'true'
|
|
continue-on-error: true
|
|
name: terminal rendering release evidence ${{ matrix.platform }}
|
|
runs-on: ${{ matrix.os }}
|
|
timeout-minutes: 35
|
|
env:
|
|
NODE_OPTIONS: --max-old-space-size=4096
|
|
strategy:
|
|
fail-fast: false
|
|
matrix:
|
|
include:
|
|
- os: ubuntu-latest
|
|
platform: linux
|
|
- os: macos-15
|
|
platform: mac
|
|
# Why: Windows release evidence currently fails on CI runner PTY
|
|
# readiness before reaching the rendering assertions.
|
|
# - os: windows-latest
|
|
# platform: windows
|
|
|
|
steps:
|
|
- name: Checkout
|
|
uses: actions/checkout@v6
|
|
with:
|
|
ref: refs/tags/${{ needs.cut.outputs.tag }}
|
|
|
|
- name: Install native build tools
|
|
if: runner.os == 'Linux'
|
|
run: sudo apt-get update && sudo apt-get install -y build-essential python3 xvfb
|
|
|
|
- name: Setup pnpm
|
|
uses: pnpm/setup@v2
|
|
with:
|
|
install: false
|
|
|
|
- name: Setup Node.js
|
|
uses: actions/setup-node@v6
|
|
with:
|
|
node-version-file: package.json
|
|
check-latest: true
|
|
cache: pnpm
|
|
|
|
# Why: keep the non-blocking evidence lane on the same Linux native
|
|
# install path as the blocking golden and release build jobs.
|
|
- name: Use external node-gyp to avoid pnpm's bundled copy (Linux only)
|
|
if: runner.os == 'Linux'
|
|
run: |
|
|
npm install -g node-gyp@11.5.0
|
|
echo "npm_config_node_gyp=$(npm root -g)/node-gyp/bin/node-gyp.js" >> "$GITHUB_ENV"
|
|
|
|
# Same node-gyp header fetch as the blocking golden gate above.
|
|
- name: Install dependencies
|
|
uses: nick-fields/retry@v4
|
|
with:
|
|
timeout_minutes: 10
|
|
max_attempts: 3
|
|
retry_wait_seconds: 30
|
|
command: pnpm install --frozen-lockfile
|
|
|
|
- name: Build Electron app for terminal rendering evidence
|
|
run: npx electron-vite build --mode e2e
|
|
|
|
- name: Run terminal rendering evidence on Linux
|
|
if: runner.os == 'Linux'
|
|
run: xvfb-run --auto-servernum env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run test:e2e:terminal-rendering-release-evidence
|
|
|
|
- name: Run terminal rendering evidence on macOS
|
|
if: runner.os == 'macOS'
|
|
run: env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run test:e2e:terminal-rendering-release-evidence
|
|
|
|
- name: Run terminal rendering evidence on Windows
|
|
if: runner.os == 'Windows'
|
|
shell: pwsh
|
|
run: |
|
|
$env:SKIP_BUILD = '1'
|
|
$env:ORCA_E2E_FORWARD_APP_LOGS = '1'
|
|
pnpm run test:e2e:terminal-rendering-release-evidence
|
|
|
|
- name: Upload Playwright traces
|
|
if: failure()
|
|
uses: actions/upload-artifact@v7
|
|
with:
|
|
name: terminal-rendering-release-evidence-${{ matrix.platform }}-playwright-traces
|
|
path: test-results/
|
|
retention-days: 7
|
|
if-no-files-found: ignore
|
|
|
|
# Design D2: every desktop build ships the orcad template (server JS plus every target's
|
|
# addons), merged from the node-server lanes that qualified each slot at this tag. It needs no
|
|
# signing quota, so it runs beside the release gates instead of behind them.
|
|
orcad-template:
|
|
needs: cut
|
|
if: needs.cut.outputs.should_release == 'true' && needs.cut.outputs.ships_orcad_template == 'true'
|
|
permissions:
|
|
contents: read
|
|
actions: read
|
|
uses: ./.github/workflows/qualified-orcad-template.yml
|
|
with:
|
|
ref: refs/tags/${{ needs.cut.outputs.tag }}
|
|
|
|
# Why: artifact jobs submit Windows binaries to SignPath. Keep every
|
|
# quota-consuming build behind all blocking release gates so a late test
|
|
# failure cannot create signing requests that can never be published.
|
|
release-preflight:
|
|
needs:
|
|
- cut
|
|
- terminal-rendering-golden
|
|
- skill-sharing-release-gate
|
|
- skill-sharing-linux-floor-release-gate
|
|
if: >-
|
|
always() &&
|
|
needs.cut.outputs.should_release == 'true' &&
|
|
needs.terminal-rendering-golden.result == 'success' &&
|
|
needs.skill-sharing-release-gate.result == 'success' &&
|
|
needs.skill-sharing-linux-floor-release-gate.result == 'success'
|
|
runs-on: ubuntu-latest
|
|
permissions:
|
|
contents: read
|
|
steps:
|
|
- name: Confirm blocking release gates passed
|
|
run: echo "All blocking release gates passed; artifact builds may start."
|
|
|
|
# Compilation can overlap the release gates; signing remains behind release-preflight.
|
|
release-javascript:
|
|
needs: cut
|
|
if: needs.cut.outputs.should_release == 'true'
|
|
permissions:
|
|
contents: read
|
|
uses: ./.github/workflows/release-javascript.yml
|
|
with:
|
|
ref: refs/tags/${{ needs.cut.outputs.tag }}
|
|
secrets:
|
|
ORCA_POSTHOG_WRITE_KEY: ${{ secrets.ORCA_POSTHOG_WRITE_KEY }}
|
|
|
|
build:
|
|
needs:
|
|
- cut
|
|
- create-release
|
|
- orcad-template
|
|
- release-preflight
|
|
- release-javascript
|
|
- build-mac
|
|
# Why not the implicit success(): a tag without the orcad template skips that job on purpose.
|
|
if: >-
|
|
!cancelled() &&
|
|
needs.cut.result == 'success' &&
|
|
needs.cut.outputs.should_release == 'true' &&
|
|
needs.create-release.result == 'success' &&
|
|
needs.release-preflight.result == 'success' &&
|
|
needs.release-javascript.result == 'success' &&
|
|
needs.build-mac.result == 'success' &&
|
|
(needs.orcad-template.result == 'success' ||
|
|
(needs.orcad-template.result == 'skipped' && needs.cut.outputs.ships_orcad_template == 'false'))
|
|
env:
|
|
# beforePack and afterPack fail the package when the template is absent.
|
|
ORCA_REQUIRE_ORCAD_TEMPLATE: '1'
|
|
strategy:
|
|
fail-fast: false
|
|
matrix:
|
|
include:
|
|
# Why: windows-latest moved to the Windows 2025 / VS 2026 image before
|
|
# node-gyp could detect VS 18, breaking native dependency install.
|
|
- os: windows-2022
|
|
platform: win
|
|
release_command: 'node config/scripts/ensure-native-runtime.mjs --runtime=electron; if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }; pnpm exec electron-builder --config config/electron-builder.config.cjs --win --publish never'
|
|
eb_cache_path: |
|
|
~\AppData\Local\electron\Cache
|
|
~\AppData\Local\electron-builder\Cache
|
|
- os: ubuntu-latest
|
|
platform: linux-x64
|
|
release_command: node config/scripts/ensure-native-runtime.mjs --runtime=electron && pnpm exec electron-builder --config config/electron-builder.config.cjs --linux AppImage deb rpm --x64 --publish always -c.publish.releaseType=draft
|
|
unpacked_dir: dist/linux-unpacked
|
|
eb_cache_path: |
|
|
~/.cache/electron
|
|
~/.cache/electron-builder
|
|
- os: ubuntu-24.04-arm
|
|
platform: linux-arm64
|
|
release_command: node config/scripts/ensure-native-runtime.mjs --runtime=electron && ORCA_LINUX_ARM64_RELEASE=1 pnpm exec electron-builder --config config/electron-builder.config.cjs --linux AppImage deb rpm --arm64 --publish always -c.publish.releaseType=draft
|
|
unpacked_dir: dist/linux-arm64-unpacked
|
|
eb_cache_path: |
|
|
~/.cache/electron
|
|
~/.cache/electron-builder
|
|
|
|
runs-on: ${{ matrix.os }}
|
|
# Why: hosted runners hard-cap jobs at 6h; the Windows SignPath waits
|
|
# (1h inner + 4h installer) are budgeted to fit under this with the
|
|
# build itself, so a slow approval can't kill the job mid-flow.
|
|
timeout-minutes: 360
|
|
|
|
permissions:
|
|
actions: read
|
|
contents: write
|
|
|
|
steps:
|
|
- name: Checkout
|
|
uses: actions/checkout@v6
|
|
with:
|
|
ref: refs/tags/${{ needs.cut.outputs.tag }}
|
|
|
|
# GitHub reruns also resume jobs skipped behind a failed gate. Never
|
|
# recreate Windows signing requests on a rerun; reuse the assets from the
|
|
# original attempt and require a fresh dispatch if they are missing.
|
|
- name: Skip Windows artifact rebuild on rerun
|
|
if: matrix.platform == 'win' && github.run_attempt != 1
|
|
shell: bash
|
|
run: |
|
|
echo "Windows artifact/signing steps are disabled on reruns (attempt $GITHUB_RUN_ATTEMPT)."
|
|
echo "Existing signed release assets must be reused; dispatch a fresh release only when a rebuild is required." >> "$GITHUB_STEP_SUMMARY"
|
|
|
|
# Why: `uses: ./…` resolves from the checked-out tag, not from the workflow
|
|
# ref, so cutting from an older/off-main ref whose tree predates a composite
|
|
# action would fail the step with "Can't find 'action.yml'". Restore the
|
|
# actions directory from the commit this workflow file itself came from.
|
|
# Not Windows-only: every platform now consumes install-mobile-dependencies, so
|
|
# any of them can be the one whose cut ref predates the action.
|
|
- name: Restore draft-publish scripts from the workflow ref
|
|
# Why: this job checks out the release tag, so a cut from an older SHA
|
|
# still has electron-builder releaseType:release and no re-draft helper.
|
|
# The workflow YAML is from main; restore the scripts it invokes.
|
|
shell: bash
|
|
env:
|
|
WORKFLOW_SHA: ${{ github.workflow_sha }}
|
|
run: |
|
|
set -euo pipefail
|
|
git fetch --no-tags --depth=1 origin "$WORKFLOW_SHA"
|
|
git checkout "$WORKFLOW_SHA" -- config/scripts/assert-github-release-is-draft.mjs config/scripts/run-release-mac-build-workflow.mjs
|
|
|
|
- name: Restore composite actions from the workflow ref
|
|
shell: bash
|
|
env:
|
|
WORKFLOW_SHA: ${{ github.workflow_sha }}
|
|
PLATFORM: ${{ matrix.platform }}
|
|
run: |
|
|
set -euo pipefail
|
|
required=(.github/actions/install-mobile-dependencies/action.yml)
|
|
if [ "$PLATFORM" = win ] && [ "$GITHUB_RUN_ATTEMPT" = 1 ]; then
|
|
required+=(.github/actions/install-signpath-module/action.yml)
|
|
fi
|
|
missing=()
|
|
for action_path in "${required[@]}"; do
|
|
[ -f "$action_path" ] || missing+=("$action_path")
|
|
done
|
|
if [ "${#missing[@]}" -eq 0 ]; then
|
|
echo "Composite actions already present at the cut ref."
|
|
exit 0
|
|
fi
|
|
echo "Cut ref predates ${missing[*]}; restoring from $WORKFLOW_SHA."
|
|
git fetch --no-tags --depth=1 origin "$WORKFLOW_SHA"
|
|
git checkout "$WORKFLOW_SHA" -- .github/actions
|
|
for action_path in "${required[@]}"; do
|
|
test -f "$action_path"
|
|
done
|
|
|
|
# pnpm must be on PATH before setup-node so setup-node can locate the store for caching.
|
|
- name: Setup pnpm
|
|
uses: pnpm/setup@v2
|
|
with:
|
|
install: false
|
|
|
|
- name: Setup Node.js
|
|
uses: actions/setup-node@v6
|
|
with:
|
|
node-version-file: package.json
|
|
check-latest: true
|
|
cache: pnpm
|
|
cache-dependency-path: |
|
|
pnpm-lock.yaml
|
|
mobile/pnpm-lock.yaml
|
|
|
|
# Why: release builds hit the same native-module postinstall path as
|
|
# PR CI, so keep the pinned node-gyp override here too instead of
|
|
# relying on pnpm's bundled copy. Scoped to Linux via runner.os (not
|
|
# a specific matrix image) because the failing postinstall has only
|
|
# been observed on Linux runners — see run 25081763129. The macOS
|
|
# and Windows release jobs exercise the same pnpm install path and
|
|
# have not reproduced it, so keep the gate narrow until we know why.
|
|
# Using runner.os instead of matrix.os == 'ubuntu-latest' means the
|
|
# gate still works if another Linux matrix entry is added later.
|
|
- name: Use external node-gyp to avoid pnpm's bundled copy (Linux only)
|
|
if: runner.os == 'Linux'
|
|
run: |
|
|
npm install -g node-gyp@11.5.0
|
|
echo "npm_config_node_gyp=$(npm root -g)/node-gyp/bin/node-gyp.js" >> "$GITHUB_ENV"
|
|
|
|
# Cache the Electron binary + electron-builder tool downloads
|
|
# (winCodeSign, nsis, squirrel, AppImage). Saves ~30-90s per job.
|
|
- name: Cache electron-builder downloads
|
|
uses: actions/cache@v5
|
|
with:
|
|
path: ${{ matrix.eb_cache_path }}
|
|
key: electron-builder-${{ matrix.platform }}-${{ hashFiles('pnpm-lock.yaml') }}
|
|
restore-keys: |
|
|
electron-builder-${{ matrix.platform }}-
|
|
|
|
# PRs cache tools separately from Electron; identical paths preserve their cache version.
|
|
- name: Seed shared Linux packaging downloads
|
|
if: matrix.platform == 'linux-x64' && github.ref == 'refs/heads/main'
|
|
uses: actions/cache@v5
|
|
with:
|
|
path: ~/.cache/electron-builder
|
|
key: electron-builder-linux-${{ hashFiles('pnpm-lock.yaml') }}
|
|
# The release cache above restores the same tools, so this only needs to save on a miss.
|
|
lookup-only: true
|
|
|
|
# Why: pnpm install triggers electron's postinstall, which downloads the
|
|
# Electron binary from GitHub release assets. GitHub's download CDN
|
|
# occasionally returns 504s that fail the whole release. Retry on
|
|
# failure so transient network errors don't require a manual re-run.
|
|
# Why host-only: this job packages only for its own runner OS and
|
|
# architecture, so the default host-scoped install is deliberate.
|
|
- name: Install dependencies
|
|
uses: nick-fields/retry@v4
|
|
with:
|
|
timeout_minutes: 10
|
|
max_attempts: 3
|
|
retry_wait_seconds: 30
|
|
command: pnpm install --frozen-lockfile
|
|
|
|
# Why here: electron-builder's beforePack requires out/mobile-web, and the bundle
|
|
# build resolves React Native and Expo from mobile/node_modules.
|
|
- uses: ./.github/actions/install-mobile-dependencies
|
|
|
|
# Why: `pnpm build:release` verifies the Linux computer-use provider by
|
|
# importing AT-SPI bindings, which are runtime package deps but are not
|
|
# present on stock GitHub Ubuntu release runners.
|
|
# Why: `rpm` is needed by electron-builder's fpm backend to produce the
|
|
# .rpm artifact. Stock Ubuntu runners do not ship it.
|
|
- name: Install Linux computer-use provider dependencies
|
|
if: runner.os == 'Linux'
|
|
run: sudo apt-get update && sudo apt-get install -y python3-gi gir1.2-atspi-2.0 at-spi2-core xclip xdotool rpm
|
|
|
|
# Why: telemetry's transport gate (`src/main/telemetry/client.ts:IS_OFFICIAL_BUILD`)
|
|
# requires the build identity to be the literal string `stable` or `rc`,
|
|
# substituted by electron-vite's `define` block at build time. Derive
|
|
# that identity from the release tag here — `stable` for plain semver
|
|
# (`vX.Y.Z`), `rc` for prerelease (`vX.Y.Z-rc.N`). The strict regex is
|
|
# a safety net: this workflow only fires on cut-tags that already match
|
|
# one of those shapes, but if a future change ever loosens that, we
|
|
# refuse to ship rather than let an unclassified build go out with
|
|
# `BUILD_IDENTITY = null`.
|
|
- name: Classify release tag for telemetry build identity
|
|
id: tag-classify
|
|
shell: bash
|
|
env:
|
|
TAG: ${{ needs.cut.outputs.tag }}
|
|
run: |
|
|
set -euo pipefail
|
|
# Why the optional trailing identifier: suffixed side-branch RCs
|
|
# (vX.Y.Z-rc.N.perf) are rc-channel prerelease builds — same telemetry
|
|
# identity as plain RCs.
|
|
if [[ "$TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+-rc\.[0-9]+(\.[0-9A-Za-z]+)?$ ]]; then
|
|
identity=rc
|
|
elif [[ "$TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
|
|
identity=stable
|
|
else
|
|
echo "::error::Tag $TAG does not match stable or rc pattern; refusing to build official artifact"
|
|
exit 1
|
|
fi
|
|
echo "identity=$identity" >>"$GITHUB_OUTPUT"
|
|
echo "Classified $TAG as $identity"
|
|
|
|
# Why an explicit step rather than trusting the runner image: the packaged
|
|
# CLI launcher is a native Rust binary, and a Windows host without cargo
|
|
# fails deep inside electron-builder's native hook instead of here. Real
|
|
# Windows dev machines do not have it by default either.
|
|
- name: Ensure the Rust toolchain for the Windows CLI launcher
|
|
if: matrix.platform == 'win'
|
|
shell: pwsh
|
|
run: |
|
|
if (-not (Get-Command cargo -ErrorAction SilentlyContinue)) {
|
|
rustup toolchain install stable --profile minimal
|
|
rustup default stable
|
|
}
|
|
cargo --version
|
|
|
|
# Consumers verify the same official build configuration before restoring the bundle.
|
|
- name: Download release JavaScript
|
|
if: needs.release-javascript.outputs.supported == 'true'
|
|
uses: actions/download-artifact@v8
|
|
with:
|
|
name: release-javascript
|
|
path: .build/release-javascript
|
|
|
|
- name: Build app
|
|
shell: bash
|
|
run: |
|
|
if [ "$SHARED_JAVASCRIPT" = true ]; then
|
|
node config/scripts/release-javascript-artifact.mjs restore
|
|
pnpm run build:release:host
|
|
else
|
|
pnpm run build:release
|
|
fi
|
|
env:
|
|
# Why: Vite's web build crossed Node's default old-space ceiling on
|
|
# the macOS release runner, leaving v1.4.2-rc.8 as an incomplete draft.
|
|
NODE_OPTIONS: --max-old-space-size=4096
|
|
ORCA_BUILD_IDENTITY: ${{ steps.tag-classify.outputs.identity }}
|
|
SHARED_JAVASCRIPT: ${{ needs.release-javascript.outputs.supported }}
|
|
ORCA_RELEASE_JAVASCRIPT_SOURCE_SHA: ${{ needs.release-javascript.outputs.source_sha }}
|
|
ORCA_DIAGNOSTICS_TOKEN_URL: https://www.onorca.dev/diagnostics/token
|
|
ORCA_POSTHOG_WRITE_KEY: ${{ secrets.ORCA_POSTHOG_WRITE_KEY }}
|
|
|
|
# After the app build so nothing that cleans out/ can drop it; electron-builder ships it.
|
|
- name: Download the orcad deployment template
|
|
if: needs.cut.outputs.ships_orcad_template == 'true'
|
|
uses: actions/download-artifact@v8
|
|
with:
|
|
name: orcad-template
|
|
path: out/orcad-template
|
|
|
|
- name: Gate runtime file-watcher process isolation
|
|
if: runner.os == 'Linux'
|
|
run: |
|
|
# Why: #8212 is a native-process crash contract. Prove both the Node
|
|
# host and the exact Electron runtime survive SIGSEGV before packaging.
|
|
node config/scripts/runtime-file-watcher-fault-harness.mjs
|
|
ELECTRON_RUN_AS_NODE=1 pnpm exec electron config/scripts/runtime-file-watcher-fault-harness.mjs
|
|
|
|
# Why: main ships minified with sourcemap:'hidden' and packaging drops
|
|
# out/**/*.map from app.asar, so a crash trace from a released build is
|
|
# otherwise undecodable. The main bundle is platform-independent, so one
|
|
# leg publishes the maps for the whole release.
|
|
- name: Bundle main-process source maps
|
|
id: bundle-main-sourcemaps
|
|
if: matrix.platform == 'linux-x64'
|
|
shell: bash
|
|
env:
|
|
TAG: ${{ needs.cut.outputs.tag }}
|
|
run: |
|
|
set -euo pipefail
|
|
if [ -z "$(find out/main -name '*.js.map' -print -quit)" ]; then
|
|
# Older cut tags predate the hidden-source-map build setting. They
|
|
# are valid legacy releases, but have no map bundle to publish.
|
|
if grep -Eq "sourcemap:[[:space:]]*['\"]hidden['\"]" electron.vite.config.ts; then
|
|
echo "::error::No main-process source maps in out/main despite build.sourcemap='hidden'."
|
|
exit 1
|
|
fi
|
|
echo "has_maps=false" >>"$GITHUB_OUTPUT"
|
|
echo "::notice::Cut ref predates hidden main-process source maps; skipping map publication."
|
|
exit 0
|
|
fi
|
|
echo "has_maps=true" >>"$GITHUB_OUTPUT"
|
|
# Why: every entry in electron-builder's `files` is a negation, so
|
|
# app-builder prepends `**/*` and packs anything left in the workspace
|
|
# root into app.asar. Stage the bundle outside the checkout instead.
|
|
find out/main -name '*.js.map' -print | sort | zip -q -X "$RUNNER_TEMP/orca-sourcemaps-$TAG.zip" -@
|
|
ls -l "$RUNNER_TEMP/orca-sourcemaps-$TAG.zip"
|
|
|
|
- name: Publish main-process source maps
|
|
if: matrix.platform == 'linux-x64' && steps.bundle-main-sourcemaps.outputs.has_maps == 'true'
|
|
uses: nick-fields/retry@v4
|
|
with:
|
|
timeout_minutes: 10
|
|
max_attempts: 3
|
|
retry_wait_seconds: 30
|
|
command: gh release upload "${{ needs.cut.outputs.tag }}" "${{ runner.temp }}/orca-sourcemaps-${{ needs.cut.outputs.tag }}.zip" --clobber --repo "${{ github.repository }}"
|
|
env:
|
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
|
|
- name: Publish release artifacts (Linux)
|
|
if: matrix.platform == 'linux-x64' || matrix.platform == 'linux-arm64'
|
|
uses: nick-fields/retry@v4
|
|
with:
|
|
timeout_minutes: 30
|
|
max_attempts: 3
|
|
retry_wait_seconds: 30
|
|
command: ${{ matrix.release_command }}
|
|
env:
|
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
|
|
- name: Load packaged node-pty on the Linux floor
|
|
if: matrix.platform == 'linux-x64' || matrix.platform == 'linux-arm64'
|
|
uses: nick-fields/retry@v4
|
|
with:
|
|
timeout_minutes: 10
|
|
max_attempts: 3
|
|
retry_wait_seconds: 30
|
|
command: >-
|
|
node config/scripts/run-linux-packaged-node-pty-floor-smoke.mjs
|
|
--app-dir ${{ matrix.unpacked_dir }}
|
|
|
|
# Why: SignPath signs GitHub workflow artifacts, so Windows builds must
|
|
# upload only after the production-signed installer has been returned.
|
|
- name: Build Windows release artifacts
|
|
if: matrix.platform == 'win' && github.run_attempt == 1
|
|
uses: nick-fields/retry@v4
|
|
with:
|
|
timeout_minutes: 30
|
|
max_attempts: 3
|
|
retry_wait_seconds: 30
|
|
command: ${{ matrix.release_command }}
|
|
env:
|
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
# The release command prepares and verifies this host's Electron native modules first.
|
|
ORCA_REUSE_PREPARED_NATIVE_RUNTIME: '1'
|
|
# Why: the NSIS uninstaller only exists inside electron-builder's
|
|
# uninstaller pass, which deletes it right after embedding it. The sign
|
|
# hook in config/scripts/windows-uninstaller-signing.cjs copies it out
|
|
# here so it can ride the inner-binaries SignPath request below.
|
|
# Why runner.temp and never the workspace: `files` in
|
|
# config/electron-builder.config.cjs is all-negation, so app-builder
|
|
# prepends `**/*` and packs whatever is left in the checkout root. This
|
|
# step retries up to 3 times; attempt 1 writes the file after packing,
|
|
# but attempts 2 and 3 would then pack the unsigned uninstaller into
|
|
# app.asar - the exact defect this chain exists to remove.
|
|
ORCA_WIN_UNINSTALLER_EXPORT_PATH: ${{ runner.temp }}\uninstaller-signing\unsigned\orca-uninstaller.exe
|
|
|
|
- name: Verify Windows node-pty ConPTY runtime
|
|
if: matrix.platform == 'win' && github.run_attempt == 1
|
|
shell: pwsh
|
|
run: |
|
|
$runtimeDir = 'dist/win-unpacked/resources/node_modules/node-pty/build/Release'
|
|
$requiredFiles = @(
|
|
"$runtimeDir/conpty.node",
|
|
"$runtimeDir/conpty/conpty.dll",
|
|
"$runtimeDir/conpty/OpenConsole.exe"
|
|
)
|
|
foreach ($file in $requiredFiles) {
|
|
if (-not (Test-Path -LiteralPath $file -PathType Leaf)) {
|
|
throw "Missing Windows node-pty runtime file: $file"
|
|
}
|
|
Get-Item -LiteralPath $file
|
|
}
|
|
|
|
- name: Install SignPath PowerShell module
|
|
id: install-signpath
|
|
if: matrix.platform == 'win' && github.run_attempt == 1
|
|
uses: ./.github/actions/install-signpath-module
|
|
|
|
# ── Windows inner-binary signing (issue #7785) ─────────────────────
|
|
# Why: SignPath cannot deep-sign inside NSIS installers, so inner PE
|
|
# files (Orca.exe, node-pty *.node, DLLs) are signed via a separate zip
|
|
# request, then the installer is rebuilt from the signed tree before the
|
|
# existing installer signing request below. The NSIS uninstaller rides
|
|
# this same request (it is the MDE update cluster: old-uninstaller.exe /
|
|
# Uninstall Orca.exe), captured through electron-builder's sign hook and
|
|
# swapped back in during the rebuild — no third approval wait. Production
|
|
# releases require the entire signing chain to succeed (#23383).
|
|
|
|
# Why: only unsigned PE files go to SignPath. Files that already carry a
|
|
# valid signature (Microsoft's OpenConsole.exe) must keep their signer.
|
|
- name: Stage unsigned inner PE files for signing
|
|
id: stage-inner
|
|
if: matrix.platform == 'win' && github.run_attempt == 1
|
|
shell: pwsh
|
|
run: |
|
|
$root = Resolve-Path 'dist/win-unpacked'
|
|
$stage = New-Item -ItemType Directory -Force -Path 'signing-stage'
|
|
$list = New-Object System.Collections.Generic.List[string]
|
|
$skipped = New-Object System.Collections.Generic.List[string]
|
|
|
|
Get-ChildItem -Path $root -Recurse -File |
|
|
Where-Object { $_.Extension -in '.exe', '.dll', '.node' } |
|
|
ForEach-Object {
|
|
$relative = [System.IO.Path]::GetRelativePath($root, $_.FullName)
|
|
# The orcad template's Linux/macOS addons are data for SSH hosts, not PE files.
|
|
if ($relative -match '^resources[\\/]orcad-template[\\/]targets[\\/](?!win32-)') {
|
|
$skipped.Add("$relative <non-Windows orcad template payload>")
|
|
return
|
|
}
|
|
$signature = Get-AuthenticodeSignature -FilePath $_.FullName
|
|
if ($signature.Status -eq 'Valid') {
|
|
$skipped.Add("$relative <already signed: $($signature.SignerCertificate.Subject)>")
|
|
return
|
|
}
|
|
$destination = Join-Path $stage.FullName $relative
|
|
New-Item -ItemType Directory -Force -Path (Split-Path $destination) | Out-Null
|
|
Copy-Item -Path $_.FullName -Destination $destination -Force
|
|
$list.Add($relative)
|
|
}
|
|
|
|
if (-not ($list -contains 'Orca.exe')) {
|
|
throw 'Orca.exe was not staged for signing; unpacked layout changed?'
|
|
}
|
|
if (-not ($list | Where-Object { $_ -like '*conpty_console_list.node' })) {
|
|
throw 'node-pty conpty_console_list.node was not staged; this is the file from issue #7785.'
|
|
}
|
|
|
|
Set-Content -Path 'inner-signing-list.txt' -Value ($list -join "`n")
|
|
Write-Host "Staged $($list.Count) unsigned PE files for signing:"
|
|
$list | ForEach-Object { Write-Host " $_" }
|
|
Write-Host "Skipped $($skipped.Count) already-signed files:"
|
|
$skipped | ForEach-Object { Write-Host " $_" }
|
|
|
|
# Why the uninstaller rides this request: it is the file MDE flagged in
|
|
# the whole update cluster (old-uninstaller.exe / Uninstall Orca.exe),
|
|
# and folding it in here costs no extra approval wait. Why it is kept
|
|
# out of inner-signing-list.txt: that list drives the copy-back into
|
|
# dist/win-unpacked, and the uninstaller does not live there — it is
|
|
# re-injected through the sign hook during the rebuild instead.
|
|
$exportedUninstaller = Join-Path $env:RUNNER_TEMP 'uninstaller-signing\unsigned\orca-uninstaller.exe'
|
|
if (-not (Test-Path -LiteralPath $exportedUninstaller)) {
|
|
throw "No exported NSIS uninstaller at $exportedUninstaller."
|
|
}
|
|
$uninstallerStagePath = Join-Path $stage.FullName 'uninstaller\orca-uninstaller.exe'
|
|
New-Item -ItemType Directory -Force -Path (Split-Path $uninstallerStagePath) -ErrorAction Stop | Out-Null
|
|
Copy-Item -LiteralPath $exportedUninstaller -Destination $uninstallerStagePath -Force -ErrorAction Stop
|
|
Write-Host 'Staged the NSIS uninstaller for signing: uninstaller\orca-uninstaller.exe'
|
|
|
|
- name: Upload unsigned inner binaries for SignPath
|
|
id: upload-unsigned-inner
|
|
if: matrix.platform == 'win' && github.run_attempt == 1 && steps.stage-inner.outcome == 'success'
|
|
uses: actions/upload-artifact@v7
|
|
with:
|
|
name: orca-windows-inner-unsigned-${{ needs.cut.outputs.tag }}
|
|
path: signing-stage/**
|
|
if-no-files-found: error
|
|
|
|
# Unsigned packaging overlaps macOS; only successful macOS builds may consume signing quota.
|
|
- name: Require macOS success before Windows signing
|
|
id: mac-signing-gate
|
|
if: matrix.platform == 'win' && github.run_attempt == 1
|
|
shell: bash
|
|
run: node config/scripts/run-release-mac-build-workflow.mjs wait
|
|
env:
|
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
RELEASE_MAC_BUILD_REF: ${{ github.ref_name }}
|
|
RELEASE_MAC_BUILD_RELEASE_RUN_ID: ${{ github.run_id }}
|
|
RELEASE_MAC_BUILD_RUN_ID: ${{ needs.build-mac.outputs.run_id }}
|
|
RELEASE_MAC_BUILD_WORKFLOW_SHA: ${{ needs.build-mac.outputs.workflow_sha }}
|
|
RELEASE_MAC_BUILD_RUN_ATTEMPT: ${{ needs.build-mac.outputs.run_attempt }}
|
|
RELEASE_MAC_BUILD_WAIT_DEADLINE_MS: ${{ needs.build-mac.outputs.wait_deadline_ms }}
|
|
RELEASE_MAC_BUILD_TAG: ${{ needs.cut.outputs.tag }}
|
|
RELEASE_MAC_BUILD_WORKFLOW: release-mac-build.yml
|
|
|
|
- name: Submit inner binaries signing request
|
|
id: submit-inner-signing
|
|
if: matrix.platform == 'win' && github.run_attempt == 1 && steps.mac-signing-gate.outcome == 'success' && steps.upload-unsigned-inner.outcome == 'success'
|
|
uses: signpath/github-action-submit-signing-request@v2
|
|
with:
|
|
api-token: ${{ secrets.SIGNPATH_API_TOKEN }}
|
|
organization-id: c37aa192-a27a-4377-9c90-5d6c95912dc0
|
|
project-slug: orca
|
|
signing-policy-slug: release-signing
|
|
artifact-configuration-slug: windows-inner-binaries-zip
|
|
github-artifact-id: ${{ steps.upload-unsigned-inner.outputs.artifact-id }}
|
|
wait-for-completion: false
|
|
|
|
- name: Notify Slack that inner-binary signing is waiting for approval
|
|
id: notify-inner-signing
|
|
continue-on-error: true
|
|
if: matrix.platform == 'win' && github.run_attempt == 1 && steps.submit-inner-signing.outcome == 'success'
|
|
shell: pwsh
|
|
env:
|
|
SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}
|
|
SIGNPATH_ORGANIZATION_ID: c37aa192-a27a-4377-9c90-5d6c95912dc0
|
|
SIGNPATH_REQUEST_ID: ${{ steps.submit-inner-signing.outputs.signing-request-id }}
|
|
SIGNPATH_REQUEST_URL: ${{ steps.submit-inner-signing.outputs.signing-request-web-url }}
|
|
TAG: ${{ needs.cut.outputs.tag }}
|
|
SOURCE_REF: ${{ needs.cut.outputs.source_ref }}
|
|
SOURCE_SHA: ${{ needs.cut.outputs.source_sha }}
|
|
SOURCE_SHORT_SHA: ${{ needs.cut.outputs.source_short_sha }}
|
|
# Prefer triggering_actor so re-runs name who re-ran; fall back to actor.
|
|
CUT_BY: ${{ github.triggering_actor || github.actor }}
|
|
REPO_URL: ${{ github.server_url }}/${{ github.repository }}
|
|
GITHUB_RUN_URL: https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}
|
|
run: |
|
|
if ([string]::IsNullOrWhiteSpace($env:SLACK_WEBHOOK_URL)) {
|
|
throw 'SLACK_WEBHOOK_URL secret is required so release approvers know when SignPath is waiting.'
|
|
}
|
|
|
|
$requestUrl = $env:SIGNPATH_REQUEST_URL
|
|
if ([string]::IsNullOrWhiteSpace($requestUrl)) {
|
|
$requestUrl = "https://app.signpath.io/Web/$env:SIGNPATH_ORGANIZATION_ID/SigningRequests/$env:SIGNPATH_REQUEST_ID"
|
|
}
|
|
|
|
# Why: approvers need tag + source ref/commit + who cut, not only the tag.
|
|
$sourceRef = if (-not [string]::IsNullOrWhiteSpace($env:SOURCE_REF)) { $env:SOURCE_REF } else { 'unknown' }
|
|
$shortSha = if (-not [string]::IsNullOrWhiteSpace($env:SOURCE_SHORT_SHA)) {
|
|
$env:SOURCE_SHORT_SHA
|
|
} elseif (-not [string]::IsNullOrWhiteSpace($env:SOURCE_SHA)) {
|
|
$env:SOURCE_SHA.Substring(0, [Math]::Min(12, $env:SOURCE_SHA.Length))
|
|
} else {
|
|
'unknown'
|
|
}
|
|
$commitLink = if (-not [string]::IsNullOrWhiteSpace($env:SOURCE_SHA)) {
|
|
"<$($env:REPO_URL)/commit/$($env:SOURCE_SHA)|``$shortSha``>"
|
|
} else {
|
|
"``$shortSha``"
|
|
}
|
|
$cutBy = if (-not [string]::IsNullOrWhiteSpace($env:CUT_BY)) {
|
|
"<https://github.com/$($env:CUT_BY)|@$($env:CUT_BY)>"
|
|
} else {
|
|
'unknown'
|
|
}
|
|
$message = "Orca Windows release ``$($env:TAG)`` inner-binaries signing request (1 of 2) is ready for SignPath approval.`nSource: ``$sourceRef`` @ $commitLink · cut by $cutBy`n<$requestUrl|Open SignPath signing request>`n<$($env:GITHUB_RUN_URL)|Open GitHub Actions run>"
|
|
$payload = @{
|
|
text = $message
|
|
blocks = @(
|
|
@{
|
|
type = 'section'
|
|
text = @{
|
|
type = 'mrkdwn'
|
|
text = $message
|
|
}
|
|
}
|
|
)
|
|
} | ConvertTo-Json -Depth 5
|
|
|
|
Invoke-RestMethod -Method Post -Uri $env:SLACK_WEBHOOK_URL -ContentType 'application/json' -Body $payload
|
|
|
|
# Approval remains required even when the notification service is unavailable.
|
|
- name: Download signed inner binaries from SignPath
|
|
id: download-signed-inner
|
|
if: matrix.platform == 'win' && github.run_attempt == 1 && steps.submit-inner-signing.outcome == 'success'
|
|
shell: pwsh
|
|
env:
|
|
SIGNPATH_API_TOKEN: ${{ secrets.SIGNPATH_API_TOKEN }}
|
|
SIGNPATH_REQUEST_ID: ${{ steps.submit-inner-signing.outputs.signing-request-id }}
|
|
run: |
|
|
Get-SignedArtifact `
|
|
-OrganizationId c37aa192-a27a-4377-9c90-5d6c95912dc0 `
|
|
-ApiToken $env:SIGNPATH_API_TOKEN `
|
|
-SigningRequestId $env:SIGNPATH_REQUEST_ID `
|
|
-OutputArtifactPath signed-inner.zip `
|
|
-Force `
|
|
-WaitForCompletionTimeoutInSeconds 3600
|
|
|
|
New-Item -ItemType Directory -Path signed-inner -Force
|
|
Expand-Archive -Path signed-inner.zip -DestinationPath signed-inner -Force
|
|
|
|
# Why: copy back strictly by the staged list so a layout mismatch in the
|
|
# returned artifact fails before publication instead of silently
|
|
# shipping a mix of signed and unsigned binaries.
|
|
- name: Restore signed inner binaries into unpacked app
|
|
id: restore-signed-inner
|
|
if: matrix.platform == 'win' && github.run_attempt == 1 && steps.download-signed-inner.outcome == 'success'
|
|
shell: pwsh
|
|
env:
|
|
SIGNING_POLICY: release-signing
|
|
run: |
|
|
$requireValid = $env:SIGNING_POLICY -ne 'test-signing'
|
|
$root = Resolve-Path 'dist/win-unpacked'
|
|
$failures = New-Object System.Collections.Generic.List[string]
|
|
foreach ($relative in Get-Content 'inner-signing-list.txt') {
|
|
$candidates = @(
|
|
(Join-Path 'signed-inner' $relative),
|
|
(Join-Path 'signed-inner/signing-stage' $relative)
|
|
) | Where-Object { Test-Path -LiteralPath $_ -PathType Leaf }
|
|
if (@($candidates).Count -ne 1) {
|
|
$failures.Add("missing or ambiguous signed artifact path: $relative")
|
|
continue
|
|
}
|
|
$signed = Get-Item -LiteralPath @($candidates)[0]
|
|
$signature = Get-AuthenticodeSignature -FilePath $signed.FullName
|
|
if ($null -eq $signature.SignerCertificate -or ($requireValid -and ($signature.Status -ne 'Valid' -or $signature.SignerCertificate.Subject -notlike '*CN=SignPath Foundation*'))) {
|
|
$failures.Add("returned without a valid SignPath signature: $relative")
|
|
continue
|
|
}
|
|
Copy-Item -Path $signed.FullName -Destination (Join-Path $root $relative) -Force
|
|
Write-Host ("{0,-14} {1} <{2}>" -f $signature.Status, $relative, $signature.SignerCertificate.Subject)
|
|
}
|
|
if ($failures.Count -gt 0) {
|
|
$failures | ForEach-Object { Write-Host "::error::$_" }
|
|
throw "Signed inner artifact did not round-trip cleanly ($($failures.Count) failures)."
|
|
}
|
|
|
|
# Why: SignPath rewrote the template's Windows binaries, and the client materializer checks
|
|
# each file against the template manifest, so it must record the signed bytes.
|
|
- name: Reseal the orcad template over its signed binaries
|
|
id: reseal-orcad-template
|
|
if: matrix.platform == 'win' && github.run_attempt == 1 && steps.restore-signed-inner.outcome == 'success' && needs.cut.outputs.ships_orcad_template == 'true'
|
|
run: node config/scripts/packaged-orcad-template.cjs --reseal-signed dist/win-unpacked inner-signing-list.txt
|
|
|
|
# The uninstaller must return signed before rebuilding the installer.
|
|
- name: Restore signed uninstaller for the installer rebuild
|
|
id: restore-signed-uninstaller
|
|
if: matrix.platform == 'win' && github.run_attempt == 1 && steps.restore-signed-inner.outcome == 'success'
|
|
shell: pwsh
|
|
env:
|
|
SIGNING_POLICY: release-signing
|
|
run: |
|
|
$requireValid = $env:SIGNING_POLICY -ne 'test-signing'
|
|
$candidates = @(
|
|
(Join-Path 'signed-inner' 'uninstaller\orca-uninstaller.exe'),
|
|
(Join-Path 'signed-inner/signing-stage' 'uninstaller\orca-uninstaller.exe')
|
|
) | Where-Object { Test-Path -LiteralPath $_ -PathType Leaf }
|
|
if (@($candidates).Count -ne 1) {
|
|
throw 'Missing or ambiguous uninstaller/orca-uninstaller.exe in the signed artifact; check the windows-inner-binaries-zip configuration.'
|
|
}
|
|
$signed = Get-Item -LiteralPath @($candidates)[0]
|
|
$signature = Get-AuthenticodeSignature -FilePath $signed.FullName
|
|
if ($null -eq $signature.SignerCertificate -or ($requireValid -and ($signature.Status -ne 'Valid' -or $signature.SignerCertificate.Subject -notlike '*CN=SignPath Foundation*'))) {
|
|
throw 'The returned NSIS uninstaller carries no valid SignPath signature.'
|
|
}
|
|
$signedDir = Join-Path $env:RUNNER_TEMP 'uninstaller-signing\signed'
|
|
New-Item -ItemType Directory -Force -Path $signedDir | Out-Null
|
|
Copy-Item -LiteralPath $signed.FullName -Destination (Join-Path $signedDir 'orca-uninstaller.exe') -Force
|
|
Write-Host ("{0,-14} uninstaller <{1}>" -f $signature.Status, $signature.SignerCertificate.Subject)
|
|
|
|
# Why this step exists: electron-builder's CopyElevateHelper re-copies a
|
|
# pristine elevate.exe from its download cache over resources\elevate.exe
|
|
# on EVERY nsis pack — including the --prepackaged rebuild below — which
|
|
# clobbered the SignPath signature in v1.4.129-rc.4. There is no supported
|
|
# way to disable just the copy, so we overwrite the cache's copy with our
|
|
# signed one (identical bytes plus signature) so the clobber becomes a
|
|
# no-op. Known quirk: the cache persists across releases via actions/cache,
|
|
# so later runs may see elevate.exe as already signed and skip staging it —
|
|
# that is fine (the signature is timestamped) and the evidence gate checks
|
|
# elevate.exe in the shipped installer unconditionally.
|
|
#
|
|
# The cache lookup lives in a script because the inline path this step used
|
|
# (`<cache>\nsis`) matches no app-builder-lib layout, and `SilentlyContinue`
|
|
# plus `exit 0` turned that miss into a green step — v1.4.193 and v1.4.194
|
|
# shipped an unsigned elevate.exe that way. A miss now fails the step.
|
|
- name: Replace cached elevate.exe with the signed copy
|
|
id: sign-elevate-cache
|
|
if: matrix.platform == 'win' && github.run_attempt == 1 && steps.restore-signed-inner.outcome == 'success'
|
|
shell: pwsh
|
|
run: |
|
|
$signed = 'dist/win-unpacked/resources/elevate.exe'
|
|
if (-not (Test-Path $signed)) {
|
|
throw 'No elevate.exe in win-unpacked resources.'
|
|
}
|
|
# Why this guard stays: windows-signing-rehearsal.yml shares the
|
|
# electron-builder-win-<lockfile hash> cache key with this workflow, so a
|
|
# test-certificate elevate.exe must never be staged into a release cache.
|
|
$signature = Get-AuthenticodeSignature -FilePath $signed
|
|
$subject = if ($null -eq $signature.SignerCertificate) { '<none>' } else { $signature.SignerCertificate.Subject }
|
|
if ($signature.Status -ne 'Valid' -or $subject -notlike '*CN=SignPath Foundation*') {
|
|
throw "win-unpacked elevate.exe is not SignPath-signed ($($signature.Status), $subject)."
|
|
}
|
|
node config/scripts/replace-cached-nsis-elevate.mjs $signed
|
|
if ($LASTEXITCODE -ne 0) {
|
|
$message = 'Cached elevate.exe swap found nothing to replace; the rebuilt installer ships an unsigned UAC elevation helper (issue #7785).'
|
|
if ($env:GITHUB_STEP_SUMMARY) {
|
|
try {
|
|
Add-Content -Path $env:GITHUB_STEP_SUMMARY -Value "**Windows elevate.exe cache swap:** FAILED — $message" -ErrorAction Stop
|
|
} catch {
|
|
Write-Host "::warning::Could not write the elevate.exe swap verdict to the job summary: $_"
|
|
}
|
|
}
|
|
throw $message
|
|
}
|
|
|
|
- name: Rebuild NSIS installer from signed unpacked app
|
|
id: rebuild-nsis-signed
|
|
if: matrix.platform == 'win' && github.run_attempt == 1 && steps.restore-signed-inner.outcome == 'success'
|
|
shell: pwsh
|
|
env:
|
|
ORCA_WIN_UNINSTALLER_SIGNED_PATH: ${{ runner.temp }}\uninstaller-signing\signed\orca-uninstaller.exe
|
|
run: |
|
|
pnpm exec electron-builder --config config/electron-builder.config.cjs --win --publish never --prepackaged "$env:GITHUB_WORKSPACE\dist\win-unpacked"
|
|
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
|
|
if (-not (Test-Path 'dist/orca-windows-setup.exe')) {
|
|
throw 'electron-builder --prepackaged did not produce dist/orca-windows-setup.exe'
|
|
}
|
|
|
|
# ── End Windows inner-binary signing ───────────────────────────────
|
|
- name: Upload unsigned Windows installer for SignPath
|
|
if: matrix.platform == 'win' && github.run_attempt == 1
|
|
id: upload-unsigned-windows-installer
|
|
uses: actions/upload-artifact@v7
|
|
with:
|
|
name: orca-windows-unsigned-${{ needs.cut.outputs.tag }}
|
|
path: dist/orca-windows-setup.exe
|
|
compression-level: 0
|
|
if-no-files-found: error
|
|
|
|
# Why: SignPath Foundation production certificates require manual review,
|
|
# so the release job waits while the signing request is approved in UI.
|
|
- name: Submit Windows installer signing request
|
|
id: submit-signing-request
|
|
if: matrix.platform == 'win' && github.run_attempt == 1 && steps.mac-signing-gate.outcome == 'success' && steps.upload-unsigned-windows-installer.outcome == 'success'
|
|
uses: signpath/github-action-submit-signing-request@v2
|
|
with:
|
|
api-token: ${{ secrets.SIGNPATH_API_TOKEN }}
|
|
organization-id: c37aa192-a27a-4377-9c90-5d6c95912dc0
|
|
project-slug: orca
|
|
signing-policy-slug: release-signing
|
|
artifact-configuration-slug: github-actions-windows-installer
|
|
github-artifact-id: ${{ steps.upload-unsigned-windows-installer.outputs.artifact-id }}
|
|
wait-for-completion: false
|
|
|
|
- name: Notify Slack that Windows signing is waiting for approval
|
|
if: matrix.platform == 'win' && github.run_attempt == 1
|
|
shell: pwsh
|
|
env:
|
|
SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}
|
|
SIGNPATH_ORGANIZATION_ID: c37aa192-a27a-4377-9c90-5d6c95912dc0
|
|
SIGNPATH_REQUEST_ID: ${{ steps.submit-signing-request.outputs.signing-request-id }}
|
|
SIGNPATH_REQUEST_URL: ${{ steps.submit-signing-request.outputs.signing-request-web-url }}
|
|
TAG: ${{ needs.cut.outputs.tag }}
|
|
SOURCE_REF: ${{ needs.cut.outputs.source_ref }}
|
|
SOURCE_SHA: ${{ needs.cut.outputs.source_sha }}
|
|
SOURCE_SHORT_SHA: ${{ needs.cut.outputs.source_short_sha }}
|
|
# Prefer triggering_actor so re-runs name who re-ran; fall back to actor.
|
|
CUT_BY: ${{ github.triggering_actor || github.actor }}
|
|
REPO_URL: ${{ github.server_url }}/${{ github.repository }}
|
|
GITHUB_RUN_URL: https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}
|
|
INNER_SIGNING_SUBMITTED: ${{ steps.submit-inner-signing.outcome == 'success' }}
|
|
run: |
|
|
if ([string]::IsNullOrWhiteSpace($env:SLACK_WEBHOOK_URL)) {
|
|
throw 'SLACK_WEBHOOK_URL secret is required so release approvers know when SignPath is waiting.'
|
|
}
|
|
|
|
$requestUrl = $env:SIGNPATH_REQUEST_URL
|
|
if ([string]::IsNullOrWhiteSpace($requestUrl)) {
|
|
$requestUrl = "https://app.signpath.io/Web/$env:SIGNPATH_ORGANIZATION_ID/SigningRequests/$env:SIGNPATH_REQUEST_ID"
|
|
}
|
|
|
|
# Keep the two approval requests distinguishable in the notification.
|
|
$stage = if ($env:INNER_SIGNING_SUBMITTED -eq 'true') { 'installer signing request (2 of 2)' } else { 'signing request' }
|
|
# Why: approvers need tag + source ref/commit + who cut, not only the tag.
|
|
$sourceRef = if (-not [string]::IsNullOrWhiteSpace($env:SOURCE_REF)) { $env:SOURCE_REF } else { 'unknown' }
|
|
$shortSha = if (-not [string]::IsNullOrWhiteSpace($env:SOURCE_SHORT_SHA)) {
|
|
$env:SOURCE_SHORT_SHA
|
|
} elseif (-not [string]::IsNullOrWhiteSpace($env:SOURCE_SHA)) {
|
|
$env:SOURCE_SHA.Substring(0, [Math]::Min(12, $env:SOURCE_SHA.Length))
|
|
} else {
|
|
'unknown'
|
|
}
|
|
$commitLink = if (-not [string]::IsNullOrWhiteSpace($env:SOURCE_SHA)) {
|
|
"<$($env:REPO_URL)/commit/$($env:SOURCE_SHA)|``$shortSha``>"
|
|
} else {
|
|
"``$shortSha``"
|
|
}
|
|
$cutBy = if (-not [string]::IsNullOrWhiteSpace($env:CUT_BY)) {
|
|
"<https://github.com/$($env:CUT_BY)|@$($env:CUT_BY)>"
|
|
} else {
|
|
'unknown'
|
|
}
|
|
$message = "Orca Windows release ``$($env:TAG)`` $stage is ready for SignPath approval.`nSource: ``$sourceRef`` @ $commitLink · cut by $cutBy`n<$requestUrl|Open SignPath signing request>`n<$($env:GITHUB_RUN_URL)|Open GitHub Actions run>"
|
|
$payload = @{
|
|
text = $message
|
|
blocks = @(
|
|
@{
|
|
type = 'section'
|
|
text = @{
|
|
type = 'mrkdwn'
|
|
text = $message
|
|
}
|
|
}
|
|
)
|
|
} | ConvertTo-Json -Depth 5
|
|
|
|
Invoke-RestMethod -Method Post -Uri $env:SLACK_WEBHOOK_URL -ContentType 'application/json' -Body $payload
|
|
|
|
- name: Download signed Windows installer from SignPath
|
|
if: matrix.platform == 'win' && github.run_attempt == 1
|
|
shell: pwsh
|
|
env:
|
|
SIGNPATH_API_TOKEN: ${{ secrets.SIGNPATH_API_TOKEN }}
|
|
SIGNPATH_REQUEST_ID: ${{ steps.submit-signing-request.outputs.signing-request-id }}
|
|
run: |
|
|
Get-SignedArtifact `
|
|
-OrganizationId c37aa192-a27a-4377-9c90-5d6c95912dc0 `
|
|
-ApiToken $env:SIGNPATH_API_TOKEN `
|
|
-SigningRequestId $env:SIGNPATH_REQUEST_ID `
|
|
-OutputArtifactPath signed-windows.zip `
|
|
-Force `
|
|
-WaitForCompletionTimeoutInSeconds 14400
|
|
|
|
New-Item -ItemType Directory -Path signed-windows -Force
|
|
Expand-Archive -Path signed-windows.zip -DestinationPath signed-windows -Force
|
|
|
|
- name: Stage signed Windows release assets
|
|
if: matrix.platform == 'win' && github.run_attempt == 1
|
|
shell: pwsh
|
|
run: |
|
|
$signedInstaller = Get-ChildItem -Path signed-windows -Recurse -File -Filter 'orca-windows-setup.exe' | Select-Object -First 1
|
|
if ($null -eq $signedInstaller) {
|
|
throw 'Signed Windows installer was not returned by SignPath.'
|
|
}
|
|
|
|
Copy-Item -Path $signedInstaller.FullName -Destination 'dist/orca-windows-setup.exe' -Force
|
|
node config/scripts/generate-windows-blockmap.mjs 'dist/orca-windows-setup.exe' 'dist/orca-windows-setup.exe.blockmap'
|
|
if ($LASTEXITCODE -ne 0) { throw "blockmap generation failed with exit code $LASTEXITCODE" }
|
|
|
|
$installer = Get-Item 'dist/orca-windows-setup.exe'
|
|
$blockmap = Get-Item 'dist/orca-windows-setup.exe.blockmap'
|
|
$stream = [System.IO.File]::OpenRead($installer.FullName)
|
|
try {
|
|
$sha512 = [System.Security.Cryptography.SHA512]::Create()
|
|
$hash = [Convert]::ToBase64String($sha512.ComputeHash($stream))
|
|
} finally {
|
|
if ($null -ne $sha512) {
|
|
$sha512.Dispose()
|
|
}
|
|
$stream.Dispose()
|
|
}
|
|
|
|
$latestYml = Get-Content -Path 'dist/latest.yml' -Raw
|
|
$latestYml = [regex]::Replace($latestYml, '(?m)^(\s*)sha512: .+$', {
|
|
param($match)
|
|
"$($match.Groups[1].Value)sha512: $hash"
|
|
})
|
|
$latestYml = $latestYml -replace '(?m)^ size: \d+$', " size: $($installer.Length)"
|
|
$latestYml = $latestYml -replace '(?m)^ blockMapSize: \d+$', " blockMapSize: $($blockmap.Length)"
|
|
Set-Content -Path 'dist/latest.yml' -Value $latestYml -NoNewline
|
|
|
|
Get-Item 'dist/orca-windows-setup.exe', 'dist/orca-windows-setup.exe.blockmap', 'dist/latest.yml'
|
|
|
|
- name: Verify signed Windows installer
|
|
if: matrix.platform == 'win' && github.run_attempt == 1
|
|
shell: pwsh
|
|
run: |
|
|
$signature = Get-AuthenticodeSignature -FilePath 'dist/orca-windows-setup.exe'
|
|
if ($signature.Status -ne 'Valid') {
|
|
throw ($signature | Format-List * | Out-String)
|
|
}
|
|
if ($signature.SignerCertificate.Subject -notlike '*CN=SignPath Foundation*') {
|
|
throw "Unexpected Windows signer: $($signature.SignerCertificate.Subject)"
|
|
}
|
|
$signature.SignerCertificate | Format-List Subject,Issuer,NotBefore,NotAfter,Thumbprint
|
|
|
|
# Why: evidence gate for inner-binary signing (issue #7785, supersedes
|
|
# PR #7170's Orca.exe-only gate — this covers every staged .exe/.dll/.node
|
|
# by extracting the shipped installer). Unsigned binaries block publication.
|
|
- name: Verify Windows inner binary signatures
|
|
if: matrix.platform == 'win' && github.run_attempt == 1
|
|
shell: pwsh
|
|
env:
|
|
ORCA_WINDOWS_INNER_SIGNATURE_REQUIRED: 'true'
|
|
INNER_SIGNING_COMPLETED: ${{ steps.rebuild-nsis-signed.outcome == 'success' }}
|
|
UNINSTALLER_SIGNING_COMPLETED: ${{ steps.restore-signed-uninstaller.outcome == 'success' }}
|
|
run: |
|
|
$required = $env:ORCA_WINDOWS_INNER_SIGNATURE_REQUIRED -eq 'true'
|
|
|
|
# Keep diagnostics best-effort so they cannot mask a signature failure.
|
|
function Add-GateEvidence([string]$line) {
|
|
try {
|
|
Add-Content -Path 'inner-signing-evidence.txt' -Value "`n$line" -ErrorAction Stop
|
|
} catch {
|
|
Write-Host "::warning::Could not append to the inner-signing evidence file: $_"
|
|
}
|
|
}
|
|
|
|
function Add-GateSummary([string]$verdict) {
|
|
if (-not $env:GITHUB_STEP_SUMMARY) { return }
|
|
try {
|
|
Add-Content -Path $env:GITHUB_STEP_SUMMARY -Value "**Windows inner-binary signing:** $verdict" -ErrorAction Stop
|
|
} catch {
|
|
Write-Host "::warning::Could not write inner-signing verdict to the job summary: $_"
|
|
}
|
|
}
|
|
|
|
function Write-GateVerdict([string]$verdict) {
|
|
try {
|
|
Set-Content -Path 'inner-signing-evidence.txt' -Value $verdict -ErrorAction Stop
|
|
} catch {
|
|
Write-Host "::warning::Could not persist inner-signing verdict: $_"
|
|
}
|
|
Add-GateSummary $verdict
|
|
}
|
|
|
|
if ($env:INNER_SIGNING_COMPLETED -ne 'true') {
|
|
$message = 'Windows inner-binary signing did not complete; publication is blocked.'
|
|
Write-GateVerdict "NOT VERIFIED — $message"
|
|
if ($required) { throw $message }
|
|
Write-Host "::warning::$message"
|
|
exit 0
|
|
}
|
|
|
|
# Keep signature failures outside the catch to preserve the per-file report.
|
|
$policyFailure = $null
|
|
|
|
try {
|
|
$report = New-Object System.Collections.Generic.List[string]
|
|
$failures = New-Object System.Collections.Generic.List[string]
|
|
|
|
# Why: verify the files a user actually gets on disk, not the build
|
|
# tree — 7z parses the NSIS exe directly as its embedded payload.
|
|
# Resolve 7za via app-builder-lib; electron-builder 26.9+ dropped the
|
|
# bundled 7zip-bin package the old hardcoded path relied on (#6487).
|
|
$7zaOutput = node config/scripts/resolve-7za-path.mjs
|
|
$7zaExitCode = $LASTEXITCODE
|
|
if ($7zaExitCode -ne 0) {
|
|
throw "The 7za resolver exited with code $7zaExitCode for the inner-binary evidence gate."
|
|
}
|
|
$7za = ($7zaOutput | Out-String).Trim()
|
|
if ([string]::IsNullOrWhiteSpace($7za) -or -not (Test-Path -LiteralPath $7za -PathType Leaf)) {
|
|
throw "The 7za resolver returned an invalid path for the inner-binary evidence gate: $7za"
|
|
}
|
|
New-Item -ItemType Directory -Path inner-evidence-extract -Force | Out-Null
|
|
& $7za x 'dist/orca-windows-setup.exe' '-oinner-evidence-extract' -y | Out-Null
|
|
if ($LASTEXITCODE -ne 0) { throw 'Could not extract the shipped installer payload.' }
|
|
|
|
$root = Resolve-Path 'inner-evidence-extract'
|
|
# Verify the CLI even when a cached signed copy bypassed staging (#23383).
|
|
# Why elevate.exe is always appended: staging skips already-signed
|
|
# files, and the persisted electron-builder cache can carry a
|
|
# previously signed elevate.exe — so it may be absent from the list
|
|
# in some runs, yet it is the file most at risk of losing its
|
|
# signature in the NSIS rebuild. Verify it in every release.
|
|
$targets = @(Get-Content 'inner-signing-list.txt')
|
|
foreach ($requiredTarget in @('Orca.exe', 'resources\bin\orca.exe', 'resources\bin\orca-agent-hook.exe')) {
|
|
if ($targets -notcontains $requiredTarget) { $targets += $requiredTarget }
|
|
}
|
|
if ($targets -notcontains 'resources\elevate.exe') {
|
|
$targets += 'resources\elevate.exe'
|
|
}
|
|
# Why the uninstaller is not in $targets: NSIS embeds it in its own
|
|
# compressed data section (`File /oname=${UNINSTALL_FILENAME}` in
|
|
# app-builder-lib templates/nsis/include/installer.nsh), not in the
|
|
# app 7z payload extracted above - the bundled 7za cannot see it.
|
|
# What the receipt proves and does not: the digest comparison is
|
|
# equal by construction (the hook digests the bytes it copied from
|
|
# this same file), so the real signal is that the receipt exists at
|
|
# all - the import leg ran, and these are the bytes it embedded. The
|
|
# signature check below is the part with teeth. The shipped-artifact
|
|
# check lives in windows-signing-rehearsal.yml, which installs the
|
|
# installer and inspects the uninstaller it drops on disk.
|
|
if ($env:UNINSTALLER_SIGNING_COMPLETED -eq 'true') {
|
|
$signedUninstaller = Join-Path $env:RUNNER_TEMP 'uninstaller-signing\signed\orca-uninstaller.exe'
|
|
$receipt = "$signedUninstaller.embedded-sha256"
|
|
if (-not (Test-Path -LiteralPath $receipt)) {
|
|
$failures.Add('the sign hook did not embed the signed uninstaller into the rebuilt installer')
|
|
} else {
|
|
$embedded = (Get-Content -LiteralPath $receipt -Raw).Trim()
|
|
$actual = (Get-FileHash -LiteralPath $signedUninstaller -Algorithm SHA256).Hash.ToLowerInvariant()
|
|
$signature = Get-AuthenticodeSignature -FilePath $signedUninstaller
|
|
$subject = if ($null -eq $signature.SignerCertificate) { '<none>' } else { $signature.SignerCertificate.Subject }
|
|
$line = "{0,-14} {1} <{2}>" -f $signature.Status, 'Uninstall Orca.exe (embedded)', $subject
|
|
$report.Add($line)
|
|
Write-Host $line
|
|
if ($embedded -ne $actual) {
|
|
$failures.Add("the rebuilt installer embedded different uninstaller bytes than the signed one ($embedded vs $actual)")
|
|
} elseif ($signature.Status -ne 'Valid' -or $subject -notlike '*CN=SignPath Foundation*') {
|
|
$failures.Add("not signed by SignPath Foundation: Uninstall Orca.exe ($($signature.Status), $subject)")
|
|
}
|
|
}
|
|
} else {
|
|
$failures.Add('The NSIS uninstaller signing did not complete.')
|
|
}
|
|
foreach ($relative in $targets) {
|
|
$path = Join-Path $root $relative
|
|
if (-not (Test-Path $path)) {
|
|
$failures.Add("missing from installer payload: $relative")
|
|
continue
|
|
}
|
|
$signature = Get-AuthenticodeSignature -FilePath $path
|
|
$subject = if ($null -eq $signature.SignerCertificate) { '<none>' } else { $signature.SignerCertificate.Subject }
|
|
$line = "{0,-14} {1} <{2}>" -f $signature.Status, $relative, $subject
|
|
$report.Add($line)
|
|
Write-Host $line
|
|
if ($signature.Status -ne 'Valid' -or $subject -notlike '*CN=SignPath Foundation*') {
|
|
$failures.Add("not signed by SignPath Foundation: $relative ($($signature.Status), $subject)")
|
|
}
|
|
}
|
|
|
|
Set-Content -Path 'inner-signing-evidence.txt' -Value ($report -join "`n")
|
|
if ($failures.Count -gt 0) {
|
|
$failures | ForEach-Object { Write-Host "::warning::$_" }
|
|
$message = "Windows inner-binary evidence gate found $($failures.Count) problems."
|
|
# Why assigned before any I/O: a write that throws here would reach
|
|
# the catch with $policyFailure still null, so a required-mode
|
|
# signature failure would be re-reported as ERRORED and the per-file
|
|
# report overwritten — the exact masking the hoist exists to prevent.
|
|
if ($required) {
|
|
$policyFailure = $message
|
|
} else {
|
|
Write-Host "::warning::$message Fail-open until ORCA_WINDOWS_INNER_SIGNATURE_REQUIRED is 'true'."
|
|
}
|
|
Add-GateEvidence "VERDICT: FAILED — $message"
|
|
Add-GateSummary "FAILED — $message"
|
|
} else {
|
|
# $report, not $targets: the embedded uninstaller is reported but
|
|
# is not one of the extracted payload targets.
|
|
$ok = "All $($report.Count) checked binaries are signed by SignPath Foundation."
|
|
Add-GateEvidence "VERDICT: PASSED — $ok"
|
|
Add-GateSummary "PASSED — $ok"
|
|
Write-Host $ok
|
|
}
|
|
} catch {
|
|
Write-GateVerdict "ERRORED — $_"
|
|
if ($required) { throw }
|
|
Write-Host "::warning::Windows inner-binary evidence gate errored: $_ (fail-open, issue #7785)."
|
|
}
|
|
|
|
# Outside the catch so the FAILED evidence report survives intact.
|
|
if ($policyFailure) { throw $policyFailure }
|
|
|
|
- name: Notify Slack when Windows signing fails
|
|
if: failure() && matrix.platform == 'win' && github.run_attempt == 1 && steps.install-signpath.outcome != '' && steps.install-signpath.outcome != 'skipped'
|
|
continue-on-error: true
|
|
shell: pwsh
|
|
env:
|
|
SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}
|
|
TAG: ${{ needs.cut.outputs.tag }}
|
|
GITHUB_RUN_URL: https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}
|
|
INNER_REQUEST_ID: ${{ steps.submit-inner-signing.outputs.signing-request-id }}
|
|
INSTALLER_REQUEST_ID: ${{ steps.submit-signing-request.outputs.signing-request-id }}
|
|
INSTALLER_SUBMISSION_OUTCOME: ${{ steps.submit-signing-request.outcome }}
|
|
run: |
|
|
if ([string]::IsNullOrWhiteSpace($env:SLACK_WEBHOOK_URL)) {
|
|
throw 'SLACK_WEBHOOK_URL is missing; cannot notify release approvers of the signing failure.'
|
|
}
|
|
$stage = 'inner-binary signing or installer rebuild'
|
|
if ($env:INSTALLER_SUBMISSION_OUTCOME -in @('success', 'failure')) {
|
|
$stage = 'installer signing or final signature verification'
|
|
}
|
|
$message = "Orca Windows release ``$($env:TAG)`` failed during $stage (including approval timeouts). Publication is blocked.`n<$($env:GITHUB_RUN_URL)|Open failed GitHub Actions run>"
|
|
foreach ($request in @(
|
|
@{ Id = $env:INNER_REQUEST_ID; Label = 'Inner-binary signing request' },
|
|
@{ Id = $env:INSTALLER_REQUEST_ID; Label = 'Installer signing request' }
|
|
)) {
|
|
if (-not [string]::IsNullOrWhiteSpace($request.Id)) {
|
|
$message += "`n<https://app.signpath.io/Web/c37aa192-a27a-4377-9c90-5d6c95912dc0/SigningRequests/$($request.Id)|$($request.Label)>"
|
|
}
|
|
}
|
|
$message += "`nCheck the failed step before retrying. Late SignPath approval does not resume this run; missing Windows assets require a fresh release dispatch, not Re-run failed jobs."
|
|
$payload = @{ text = $message } | ConvertTo-Json
|
|
Invoke-RestMethod -Method Post -Uri $env:SLACK_WEBHOOK_URL -ContentType 'application/json' -Body $payload
|
|
|
|
- name: Upload Windows inner signing evidence
|
|
if: always() && matrix.platform == 'win' && github.run_attempt == 1
|
|
uses: actions/upload-artifact@v7
|
|
with:
|
|
name: orca-windows-inner-signing-evidence-${{ needs.cut.outputs.tag }}
|
|
path: |
|
|
inner-signing-evidence.txt
|
|
inner-signing-list.txt
|
|
if-no-files-found: ignore
|
|
retention-days: 30
|
|
|
|
- name: Publish signed Windows release artifacts
|
|
if: matrix.platform == 'win' && github.run_attempt == 1
|
|
uses: nick-fields/retry@v4
|
|
with:
|
|
timeout_minutes: 10
|
|
max_attempts: 3
|
|
retry_wait_seconds: 30
|
|
command: gh release upload "${{ needs.cut.outputs.tag }}" "dist/orca-windows-setup.exe" "dist/orca-windows-setup.exe.blockmap" "dist/latest.yml" --clobber --repo "${{ github.repository }}"
|
|
env:
|
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
|
|
- name: Verify release remains draft after artifact upload
|
|
# Why: electron-builder `--publish always` can create a public release
|
|
# as soon as this platform uploads. Re-draft immediately, then fail, so
|
|
# /releases/latest never keeps serving a missing Windows exe.
|
|
# Why bash: the Windows matrix defaults to pwsh, which does not expand
|
|
# "$TAG" into argv.
|
|
shell: bash
|
|
env:
|
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
TAG: ${{ needs.cut.outputs.tag }}
|
|
run: node config/scripts/assert-github-release-is-draft.mjs "${{ needs.cut.outputs.tag }}"
|
|
|
|
# Why post-pack for Linux: electron-builder packs and uploads in one
|
|
# `--publish always` invocation. The previous step re-drafts if that
|
|
# upload flipped the GitHub release public; this telemetry check still
|
|
# blocks `publish-release` from undrafting a bad binary.
|
|
#
|
|
# Why this guards against: a misconfigured CI run where
|
|
# `ORCA_POSTHOG_WRITE_KEY` is unset or the tag fails to classify
|
|
# would otherwise produce a binary with `BUILD_IDENTITY = null` and
|
|
# `WRITE_KEY = null`, which silently disables transport
|
|
# (`IS_OFFICIAL_BUILD === false`) — the exact failure mode flagged
|
|
# in PR #1385's deferred follow-up.
|
|
- name: Verify telemetry constants present in app.asar
|
|
run: node config/scripts/verify-telemetry-constants.mjs
|
|
|
|
build-mac:
|
|
needs:
|
|
- cut
|
|
- create-release
|
|
- orcad-template
|
|
- release-preflight
|
|
- release-javascript
|
|
# Why not the implicit success(): a tag without the orcad template skips that job on purpose.
|
|
if: >-
|
|
!cancelled() &&
|
|
needs.cut.result == 'success' &&
|
|
needs.cut.outputs.should_release == 'true' &&
|
|
needs.create-release.result == 'success' &&
|
|
needs.release-preflight.result == 'success' &&
|
|
needs.release-javascript.result == 'success' &&
|
|
(needs.orcad-template.result == 'success' ||
|
|
(needs.orcad-template.result == 'skipped' && needs.cut.outputs.ships_orcad_template == 'false'))
|
|
# Why: SignPath requires every job in this signing workflow to be
|
|
# GitHub-hosted. The actual mac build runs in release-mac-build.yml so
|
|
# Blacksmith stays outside Windows artifact provenance.
|
|
runs-on: ubuntu-latest
|
|
permissions:
|
|
actions: write
|
|
contents: read
|
|
outputs:
|
|
run_id: ${{ steps.dispatch.outputs.run_id }}
|
|
workflow_sha: ${{ steps.dispatch.outputs.workflow_sha }}
|
|
run_attempt: ${{ steps.dispatch.outputs.run_attempt }}
|
|
wait_deadline_ms: ${{ steps.dispatch.outputs.wait_deadline_ms }}
|
|
steps:
|
|
- name: Checkout
|
|
uses: actions/checkout@v6
|
|
|
|
- name: Setup Node.js
|
|
uses: actions/setup-node@v6
|
|
with:
|
|
node-version-file: package.json
|
|
check-latest: true
|
|
|
|
- name: Run isolated macOS release build
|
|
id: dispatch
|
|
run: node config/scripts/run-release-mac-build-workflow.mjs dispatch
|
|
env:
|
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
RELEASE_MAC_BUILD_REF: ${{ github.ref_name }}
|
|
RELEASE_MAC_BUILD_RELEASE_RUN_ID: ${{ github.run_id }}
|
|
RELEASE_MAC_BUILD_JAVASCRIPT_SOURCE_SHA: ${{ needs.release-javascript.outputs.supported == 'true' && needs.release-javascript.outputs.source_sha || '' }}
|
|
RELEASE_MAC_BUILD_TAG: ${{ needs.cut.outputs.tag }}
|
|
RELEASE_MAC_BUILD_WORKFLOW: release-mac-build.yml
|
|
|
|
verify-mac-build:
|
|
needs:
|
|
- cut
|
|
- build-mac
|
|
- build
|
|
# Qualify macOS even when a sibling build fails; publication still requires every build.
|
|
if: >-
|
|
!cancelled() &&
|
|
needs.cut.result == 'success' &&
|
|
needs.cut.outputs.should_release == 'true' &&
|
|
needs.build-mac.result == 'success'
|
|
runs-on: ubuntu-latest
|
|
timeout-minutes: 95
|
|
permissions:
|
|
actions: read
|
|
contents: read
|
|
steps:
|
|
- name: Checkout
|
|
uses: actions/checkout@v6
|
|
|
|
- name: Setup Node.js
|
|
uses: actions/setup-node@v6
|
|
with:
|
|
node-version-file: package.json
|
|
check-latest: true
|
|
|
|
- name: Require the exact isolated macOS release build to succeed
|
|
run: node config/scripts/run-release-mac-build-workflow.mjs wait
|
|
env:
|
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
RELEASE_MAC_BUILD_REF: ${{ github.ref_name }}
|
|
RELEASE_MAC_BUILD_RELEASE_RUN_ID: ${{ github.run_id }}
|
|
RELEASE_MAC_BUILD_RUN_ID: ${{ needs.build-mac.outputs.run_id }}
|
|
RELEASE_MAC_BUILD_WORKFLOW_SHA: ${{ needs.build-mac.outputs.workflow_sha }}
|
|
RELEASE_MAC_BUILD_RUN_ATTEMPT: ${{ needs.build-mac.outputs.run_attempt }}
|
|
RELEASE_MAC_BUILD_WAIT_DEADLINE_MS: ${{ needs.build-mac.outputs.wait_deadline_ms }}
|
|
RELEASE_MAC_BUILD_TAG: ${{ needs.cut.outputs.tag }}
|
|
RELEASE_MAC_BUILD_WORKFLOW: release-mac-build.yml
|
|
|
|
publish-release:
|
|
needs:
|
|
- cut
|
|
- dependency-security
|
|
- build
|
|
- build-mac
|
|
- verify-mac-build
|
|
- skill-sharing-linux-floor-release-gate
|
|
- skill-sharing-release-gate
|
|
- terminal-rendering-golden
|
|
# Why explicit: a skipped orcad-template (tags that predate it) would skip every job after it.
|
|
if: >-
|
|
!cancelled() &&
|
|
needs.cut.result == 'success' &&
|
|
(needs.dependency-security.result == 'success' || contains(needs.cut.outputs.tag, '-')) &&
|
|
needs.build.result == 'success' &&
|
|
needs.build-mac.result == 'success' &&
|
|
needs.verify-mac-build.result == 'success' &&
|
|
needs.skill-sharing-linux-floor-release-gate.result == 'success' &&
|
|
needs.skill-sharing-release-gate.result == 'success' &&
|
|
needs.terminal-rendering-golden.result == 'success'
|
|
runs-on: ubuntu-latest
|
|
permissions:
|
|
contents: write
|
|
steps:
|
|
- name: Checkout
|
|
uses: actions/checkout@v6
|
|
|
|
- name: Setup Node.js
|
|
uses: actions/setup-node@v6
|
|
with:
|
|
node-version-file: package.json
|
|
check-latest: true
|
|
|
|
- name: Verify release is still draft
|
|
env:
|
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
TAG: ${{ needs.cut.outputs.tag }}
|
|
run: |
|
|
set -euo pipefail
|
|
releases_json="$(gh api "repos/$GITHUB_REPOSITORY/releases?per_page=100")"
|
|
# Why: publish-release verifies the draft before making it visible.
|
|
draft="$(jq -e -r --arg tag "$TAG" '
|
|
map(select(.tag_name == $tag))
|
|
| if length == 1 and (.[0].draft | type) == "boolean" then (.[0].draft | tostring) else empty end
|
|
' <<<"$releases_json")" || {
|
|
echo "::error::Release $TAG was not found in the draft-aware releases list, or its draft state was missing."
|
|
exit 1
|
|
}
|
|
if [[ "$draft" != "true" ]]; then
|
|
echo "::error::Release $TAG was published before publish-release; refusing to continue."
|
|
exit 1
|
|
fi
|
|
|
|
- name: Verify release assets complete
|
|
# Why: publish-release is the only intended draft -> published
|
|
# transition. Refuse to un-draft until every updater manifest and
|
|
# referenced installer asset is present on GitHub.
|
|
env:
|
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
TAG: ${{ needs.cut.outputs.tag }}
|
|
run: node config/scripts/verify-release-required-assets.mjs "$TAG"
|
|
|
|
- name: Publish release
|
|
# Why: derive `--prerelease` from the tag shape (not from whatever
|
|
# electron-builder left the release flagged as). On 2026-04-27,
|
|
# electron-builder's publish step flipped `prerelease` back to
|
|
# `false` on -rc.N releases, which caused an RC to be marked as
|
|
# GitHub's "latest" release and broke release-cut.yml's math.
|
|
# Re-asserting here means the final release state is determined
|
|
# by the tag — a ground truth electron-builder can't rewrite.
|
|
env:
|
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
TAG: ${{ needs.cut.outputs.tag }}
|
|
run: |
|
|
set -euo pipefail
|
|
if [[ "$TAG" == *"-rc."* ]]; then
|
|
prerelease=true
|
|
else
|
|
prerelease=false
|
|
fi
|
|
gh release edit "$TAG" \
|
|
--draft=false \
|
|
--prerelease="$prerelease" \
|
|
--repo "$GITHUB_REPOSITORY"
|
|
|
|
post-release-e2e:
|
|
needs:
|
|
- cut
|
|
- publish-release
|
|
# Why explicit: a skipped orcad-template (tags that predate it) would skip every job after it.
|
|
if: ${{ !cancelled() && needs.publish-release.result == 'success' && needs.cut.outputs.tag != '' }}
|
|
runs-on: ubuntu-latest
|
|
permissions:
|
|
actions: write
|
|
steps:
|
|
- name: Dispatch tag-scoped E2E
|
|
env:
|
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
TAG: ${{ needs.cut.outputs.tag }}
|
|
run: |
|
|
for attempt in 1 2 3; do
|
|
if gh workflow run e2e.yml \
|
|
--repo "$GITHUB_REPOSITORY" \
|
|
--ref "$TAG" \
|
|
--raw-field "ref=refs/tags/$TAG"; then
|
|
echo "Dispatched post-release E2E for $TAG."
|
|
exit 0
|
|
fi
|
|
[[ "$attempt" -eq 3 ]] || sleep "$((attempt * 5))"
|
|
done
|
|
echo "::warning::Failed to dispatch post-release E2E for $TAG after 3 attempts."
|
|
|
|
docs-production-dispatch:
|
|
needs:
|
|
- cut
|
|
- publish-release
|
|
# A release created with GITHUB_TOKEN does not reliably emit a release
|
|
# event to other workflows. Dispatch the trusted default-branch workflow;
|
|
# it validates and checks out the released tag before deploying.
|
|
# Why explicit: a skipped orcad-template (tags that predate it) would skip every job after it.
|
|
if: ${{ !cancelled() && needs.publish-release.result == 'success' && needs.cut.outputs.tag != '' }}
|
|
runs-on: ubuntu-latest
|
|
permissions:
|
|
actions: write
|
|
steps:
|
|
- name: Dispatch docs deployment for stable desktop tags
|
|
env:
|
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
TAG: ${{ needs.cut.outputs.tag }}
|
|
DEFAULT_BRANCH: ${{ github.event.repository.default_branch }}
|
|
run: |
|
|
set -euo pipefail
|
|
if [[ ! "$TAG" =~ ^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$ ]]; then
|
|
echo "Skipping docs deployment for non-stable tag $TAG."
|
|
exit 0
|
|
fi
|
|
for attempt in 1 2 3; do
|
|
if gh workflow run docs.yml \
|
|
--repo "$GITHUB_REPOSITORY" \
|
|
--ref "$DEFAULT_BRANCH" \
|
|
--field "tag=$TAG"; then
|
|
echo "Dispatched docs deployment for $TAG."
|
|
exit 0
|
|
fi
|
|
[[ "$attempt" -eq 3 ]] || sleep "$((attempt * 5))"
|
|
done
|
|
echo "::error::Failed to dispatch docs deployment for $TAG after 3 attempts."
|
|
exit 1
|
|
|
|
homebrew-bump-published-rc-draft:
|
|
needs:
|
|
- cut
|
|
# Why: publish-complete-draft-releases can expose a recovered RC without
|
|
# running the build/publish jobs; still advance the RC cask to that tag.
|
|
if: ${{ needs.cut.outputs.latest_published_rc_tag != '' }}
|
|
uses: ./.github/workflows/homebrew-bump.yml
|
|
with:
|
|
tag: ${{ needs.cut.outputs.latest_published_rc_tag }}
|
|
secrets: inherit
|
|
|
|
homebrew-bump:
|
|
needs:
|
|
- cut
|
|
- publish-release
|
|
# Why explicit: a skipped orcad-template (tags that predate it) would skip every job after it.
|
|
if: ${{ !cancelled() && needs.publish-release.result == 'success' && needs.cut.outputs.tag != '' && startsWith(needs.cut.outputs.tag, 'v') }}
|
|
uses: ./.github/workflows/homebrew-bump.yml
|
|
with:
|
|
tag: ${{ needs.cut.outputs.tag }}
|
|
secrets: inherit
|