Files
orca/.github/workflows/release-cut.yml
T
Jinwoo Hong a5f7da4928 revert(daemon): ship official Node in the macOS terminal host again, keep stall telemetry (#33688)
Reverts #29133 except its daemon async-I/O stall telemetry, so we can measure how often stalls happen on official Node before committing to a patched runtime. The macOS terminal host goes back to the official Node 24.22.1 download and the daemon protocol back to v51; no release or hourly build published v52.
2026-10-25 20:07:30 -04:00

2310 lines
106 KiB
YAML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
name: Cut Release
# Resolve ref/latest stable, compute next kind, reject stable version regression, commit/tag detached and build/publish from tag. Fast-forward main’s version only when source was its tip and policy permits; PR-only rules leave main unchanged. CI replaces local/scheduled release paths to avoid accidental working-tree releases.
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:
# Limit version/tag mutations to canonical repo; fork schedules must not diverge their main and conflict upstream.
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:
# Render dispatch audit summary locally rather than run mutable external action beside write token/secrets; parse env JSON with jq as data.
- name: Summarize workflow inputs
if: github.event_name == 'workflow_dispatch'
env:
INPUTS_JSON: ${{ toJSON(inputs) }}
run: |
{
echo "## Workflow inputs"
echo ""
echo "| Input | Value |"
echo "| --- | --- |"
# Treat env JSON as data; collapse newlines so inputs cannot break the summary 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
# Fetch unpublished tags for version recovery; hidden tags can recreate/overwrite an existing version.
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:
# Keep caller ref in env data so summary labels cannot inject shell.
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"
# Push the version bump to main only when cutting its exact tip; older/off-main refs publish tags only.
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
# Show the immutable commit and matching ref links: raw ref input is ambiguous and bare SHAs hide operator-readable names.
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
)"
# Preserve literal slashes in branch/tag 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")"
# Historical SHAs may lack a matching branch tip; name-rev supplies an auditable main~N label.
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')
# Delayed schedule delivery (e.g. 4:16 AM) must not cut the 3:00 AM slot.
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: |
# Repeated target-hour schedules deduplicate via 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
# Preserve dedupe for older scheduled workflows that predate the shared 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
# Recover complete RC drafts before deciding whether manual dispatch needs 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
# Stop after RC-only draft recovery; stable dispatch still cuts its requested version.
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
# Use desktop v<digit> tag shape and numeric semver max, excluding RC/mobile tags.
# Builder flipped v1.3.22-rc.2’s prerelease flag on 2026-04-27; that flag cannot authorize stable math.
# 2026-05-04/run25304336767: mobile-v0.0.1 collapsed through NaN to 0.0.1 without the desktop prefix.
# 2026-06-04: release-list order put v1.4.42 before v1.4.44 and reopened 1.4.43 as rc.0.
latest_stable="$(node config/scripts/latest-stable-release.mjs)"
latest_stable="${latest_stable#v}"
echo "Latest stable: ${latest_stable:-<none>}"
# Strip prerelease suffixes before numeric math: Number("1-rc") becomes NaN and silently collapsed
# to 1, producing v1.3.1-rc.4 on 2026-04-27 when latest_stable was misclassified.
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() {
# Append rc.N.<identifier> so side builds sort above rc.N but below rc.N+1; clients match the suffix.
# Reject leading-zero numeric identifiers: npm normalizes rc.4.01 to rc.4.1 while tagging keeps the input.
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
# Deleted releases can regress GitHub’s version list after their bump reached main; package.json floors the current ref.
if semver_gt "$package_stable" "$latest_stable"; then
# Explicit versions must skip floor-tag recovery (which can exit successfully with the wrong tag).
# Still enforce the package floor; recover collisions for the requested tag 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
# Manual explicit versions let hotfixes from older refs leapfrog deleted shipped versions (e.g. 1.4.154 → 1.4.155)
# when published-release math regresses and the ref lacks the newer package floor. Keep updater/tag-collision gates.
new=""
if [[ -n "${EXPLICIT_VERSION:-}" ]]; then
explicit="${EXPLICIT_VERSION#v}"
# Accept one optional side-branch RC identifier, still strictly above the series head; it grants no rollback exception.
# Bound rc ordinals to nine digits without leading zeros: Bash -le misreads rc.08 as octal and wraps INTMAX,
# letting rc.99999999999999999999 pass as 7766279631452241919 and pin highest_rc_for_base at 1e20 forever.
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
# Reuse identifier validation: rc.4.01 matches shape but is invalid semver.
if [[ "$explicit" == *-rc.*.* ]]; then
require_valid_version_suffix "${explicit##*.}"
fi
# Explicit stable lines must strictly exceed published stable, ignoring prerelease identifiers.
if ! semver_gt "$explicit" "$latest_stable"; then
echo "::error::Refusing explicit version $explicit: not greater than latest stable $latest_stable." >&2
exit 1
fi
# Stable-line comparison alone admits rc.0 after rc.3 shipped. Require the override to advance its
# own series too, avoiding the rc.4 regression that orphaned live daemons.
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
# kind=rc resumes only latest stable’s next patch; minor/major RC series require explicit overrides,
# or the suggested remedy 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"
# The override bypasses kind-path suffixing: append here too, only to bare RCs, avoiding invalid
# stable X.Y.Z.perf or repeated rc.N.perf.perf suffixes.
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)
# RCs target published stable + next patch. Highest-tag continuation reopened shipped series
# (v1.3.21-rc.7 after v1.3.21 stable); cut minor/major stable first to start those RC bases.
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}"
# Resume highest draft/missing RC only when 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
# If an old orphan stable tag cannot be recovered for this ref, advance rather than wedge future patch cuts.
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
# Recover existing draft/absent releases by emitting the pushed tag and rerunning builds without bump/commit/push.
# 2026-05-01/run25237882049: cancellation after pushing v1.3.26 wedged every patch cut on that orphan tag
# until manual release dispatch. A tag plus published release is a real collision requiring operator 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
# Control commit message/tag ourselves via npm version --no-git-tag-version, avoiding npm’s prefix/lifecycle assumptions.
npm version "$VERSION" --no-git-tag-version --allow-same-version
# Tags must describe their bundled skill bytes; verify freshness with Node built-ins before 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
# A failed cut may already have bumped main; re-cut still needs a fresh taggable commit.
git commit --allow-empty -m "$commit_message"
else
git commit -m "$commit_message"
fi
# Validate the committed tree, not staged intent: git commit -a/-i/--only/pathspec can bypass the index.
# Literal -F matching cannot accidentally admit packageXjson; -m --first-parent makes merge commits visible
# to diff-tree so the allowlist cannot silently pass an empty merge diff.
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 }}
runner: ubuntu-latest
secrets:
SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}
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"
# Retry node-gyp header fetch ECONNRESET so transient install failures cannot fail the whole release.
- 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
# Use --if-present for golden scripts absent from older hotfix/cut tags, despite newer dispatch YAML.
- 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
# Build every target’s qualified orcad slot at the tag beside release gates; unsigned template consumes no signing quota.
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 }}
fast_runners: false
# Gate SignPath artifact requests behind blocking tests to avoid approvals for unpublishable builds.
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 }}
runner: ubuntu-latest
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 }}
# Fit 1h inner+4h installer SignPath waits and build under hosted runner’s 6h cap.
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"
# Restore composite actions from workflow commit: uses:./ resolves checked-out tag, which may predate mobile/dependency actions.
- name: Restore draft-publish scripts from the workflow ref
# Restore current workflow helper scripts when old cut tags still publish live and lack re-draft support.
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
# Override non-executable pnpm node-gyp only on Linux (run 25081763129); runner.os keeps future Linux entries covered.
- 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 Electron and builder downloads (winCodeSign/NSIS/Squirrel/AppImage), saving ~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
# Retry transient Electron CDN 504s during install; keep host-scoped native dependencies for this platform’s package.
- name: Install dependencies
uses: nick-fields/retry@v4
with:
timeout_minutes: 10
max_attempts: 3
retry_wait_seconds: 30
command: pnpm install --frozen-lockfile
# Build required out/mobile-web from mobile’s React Native/Expo dependencies.
- uses: ./.github/actions/install-mobile-dependencies
# Install AT-SPI runtime bindings for Linux provider verification and rpm for builder’s fpm backend; stock Ubuntu lacks them.
- 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
# Derive literal stable/rc telemetry identity from strict tag shape; refuse unclassified builds rather than ship silent null identity (#1385 follow-up).
- name: Classify release tag for telemetry build identity
id: tag-classify
shell: bash
env:
TAG: ${{ needs.cut.outputs.tag }}
run: |
set -euo pipefail
# Suffixed vX.Y.Z-rc.N.perf builds keep the same rc-channel telemetry identity.
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"
# Require cargo before builder invokes the packaged Rust launcher hook.
- 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: |
# #8212: require Node and the exact Electron runtime to survive native 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
# Publish hidden main sourcemaps separately because app.asar drops maps; one platform suffices for platform-independent main bundle.
- 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
# Old valid cut tags predate hidden sourcemaps and have no 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"
# All-negation builder files prepend **/*; stage outside checkout to avoid packing maps into app.asar.
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'
# Capture NSIS uninstaller in sign hook before builder embeds/deletes it, including it in inner-binary signing. Keep capture under runner.temp: all-negation files includes checkout leftovers, and retry would pack unsigned uninstaller into app.asar.
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
# #7785/#23383: sign inner PE zip, including captured NSIS uninstaller, then rebuild/sign installer; SignPath cannot deep-sign inside NSIS and this avoids a third approval.
# Stage only unsigned PE files; preserve existing vendor signatures such as Microsoft OpenConsole.
- 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 " $_" }
# Include MDE-flagged old-uninstaller.exe/Uninstall Orca.exe in this approval without another wait.
# Exclude it from win-unpacked’s copy-back list; the sign hook re-injects it during rebuild.
$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
# Copy back only staged paths; layout mismatch must fail before mixed signed/unsigned assets ship.
- 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)."
}
# Recompute template checksums after SignPath rewrites Windows binaries so materialization validates 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)
# Builder recopies cached elevate.exe on every NSIS pack, clobbering signature in v1.4.129-rc.4; replace cache copy with signed bytes before rebuild. Persisted timestamped signatures may skip later staging; shipped-installer evidence still checks them. Scripted lookup fails missing cache paths: old nonexistent <cache>/nsis plus silent success shipped unsigned elevate.exe in v1.4.193/v1.4.194.
- 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.'
}
# Rehearsal shares the builder cache: never seed a release with its test-certificate elevate.exe.
$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
# #7785 evidence extracts every staged exe/dll/node from shipped installer, superseding #7170’s Orca.exe-only gate; unsigned assets block publish.
- 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]
# Verify shipped NSIS payload bytes via 7z, not the build tree. Resolve 7za through app-builder-lib:
# builder 26.9+ dropped bundled 7zip-bin (#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).
# Always verify elevate.exe, even when cached signatures skipped staging; NSIS rebuild can clobber it.
$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'
}
# The uninstaller lives in NSIS’s compressed data section, outside the app 7z payload.
# A receipt proves the sign-hook import ran, not the installed bytes; its digest equality is by construction.
# Check that input’s signature here; windows-signing-rehearsal installs/inspects the actual shipped uninstaller.
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."
# Set policyFailure before I/O: write errors must not turn FAILED into ERRORED and overwrite per-file evidence.
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 {
# Use report, not extracted targets: it also includes the embedded uninstaller.
$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
# Re-draft immediately if publish:always exposed a partial release, then fail so latest cannot keep missing Windows assets. Use bash for $TAG expansion.
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 }}"
# Check Linux telemetry after inseparable pack/upload, then block final undraft. Reject null identity/key transport from missing configuration (#1385); prior step re-drafts premature publication.
- 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'))
# Keep every SignPath workflow job GitHub-hosted; separate release-mac-build allows Blacksmith outside Windows 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
- 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.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
# Only undraft after every updater manifest and referenced installer exists.
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TAG: ${{ needs.cut.outputs.tag }}
run: node config/scripts/verify-release-required-assets.mjs "$TAG"
- name: Publish release
# Reassert prerelease from tag: on 2026-04-27 builder flipped RC to false/latest and broke release math; builder output is not authoritative.
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