mirror of
https://github.com/stablyai/orca.git
synced 2026-11-13 17:37:40 +00:00
* chore(endpoints): call orca.dev for feedback, plugin lists and update notes The site moved to orca.dev. Feedback, connection diagnostics, plugin install stats, the plugin kill list, update nudges and the changelog feed now use orca.dev, as do the diagnostics token URL baked into builds and the release download mirror (downloads.orca.dev). The old hosts serve the same paths, so released builds keep working. Skill share links on share.orca.dev are accepted, and the mobile scanner recognizes the orca.dev Android download code. The old hosts stay accepted. * chore(ci): leave the release JavaScript benchmark workflow unchanged Editing it triggers the benchmark, whose parity check currently fails on arm64 because max-quality brotli output for the mobile page differs by architecture. The benchmark only needs some diagnostics URL, and the old host still serves it.
2388 lines
111 KiB
YAML
2388 lines
111 KiB
YAML
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/release/publication/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/release/versioning/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/versioning/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/skills/bundle-generation/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/orcad-distribution/template/packaged-orcad-template.cjs" 2>/dev/null || 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/release/publication/create-draft-release.mjs \
|
||
config/scripts/release/publication/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/release/publication/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
|
||
|
||
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 \
|
||
tests/e2e/helpers/e2e-completed-onboarding-profile.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: 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"
|
||
SKILL_ROOTS_TEST=src/main/skills/providers/skill-provider-runtime-roots.test.ts
|
||
if [ ! -f src/main/skills/providers/skill-provider-runtime-roots.ts ]; then
|
||
SKILL_ROOTS_TEST=src/main/skills/skill-provider-runtime-roots.test.ts
|
||
fi
|
||
git show "$WORKFLOW_SHA:src/main/skills/providers/skill-provider-runtime-roots.test.ts" > "$SKILL_ROOTS_TEST"
|
||
|
||
- uses: ./.github/actions/install-node-dependencies
|
||
with:
|
||
native-runtime: node
|
||
|
||
- name: Install Electron package binary for tests
|
||
# Why: the Windows leg would otherwise run this POSIX fallback in PowerShell.
|
||
shell: bash
|
||
run: |
|
||
ELECTRON_INSTALL_SCRIPT=config/scripts/electron-installation/platform-layout/install-electron-package-binary.mjs
|
||
if [ ! -f "$ELECTRON_INSTALL_SCRIPT" ]; then
|
||
ELECTRON_INSTALL_SCRIPT=config/scripts/install-electron-package-binary.mjs
|
||
fi
|
||
node "$ELECTRON_INSTALL_SCRIPT"
|
||
|
||
- 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"
|
||
SKILL_ROOTS_TEST=src/main/skills/providers/skill-provider-runtime-roots.test.ts
|
||
if [ ! -f src/main/skills/providers/skill-provider-runtime-roots.ts ]; then
|
||
SKILL_ROOTS_TEST=src/main/skills/skill-provider-runtime-roots.test.ts
|
||
fi
|
||
git show "$WORKFLOW_SHA:src/main/skills/providers/skill-provider-runtime-roots.test.ts" > "$SKILL_ROOTS_TEST"
|
||
|
||
- uses: ./.github/actions/install-node-dependencies
|
||
with:
|
||
native-runtime: node
|
||
|
||
- name: Install Electron package binary for tests
|
||
run: |
|
||
ELECTRON_INSTALL_SCRIPT=config/scripts/electron-installation/platform-layout/install-electron-package-binary.mjs
|
||
if [ ! -f "$ELECTRON_INSTALL_SCRIPT" ]; then
|
||
ELECTRON_INSTALL_SCRIPT=config/scripts/install-electron-package-binary.mjs
|
||
fi
|
||
node "$ELECTRON_INSTALL_SCRIPT"
|
||
|
||
- 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
|
||
|
||
# Server-only Linux tarballs from the same qualified template. Nothing depends on this job, so a
|
||
# failure here never holds the desktop release.
|
||
orca-server-artifact:
|
||
needs:
|
||
- cut
|
||
- orcad-template
|
||
if: ${{ !cancelled() && needs.cut.outputs.should_release == 'true' && needs.orcad-template.result == 'success' }}
|
||
permissions:
|
||
contents: read
|
||
actions: read
|
||
uses: ./.github/workflows/orca-server-artifact.yml
|
||
with:
|
||
ref: refs/tags/${{ needs.cut.outputs.tag }}
|
||
template_artifact: orcad-template
|
||
|
||
# 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 $(if (Test-Path config/scripts/native-build/node-addons/ensure-native-runtime.mjs) { "config/scripts/native-build/node-addons/ensure-native-runtime.mjs" } else { "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 "$(if [ -f config/scripts/native-build/node-addons/ensure-native-runtime.mjs ]; then printf %s config/scripts/native-build/node-addons/ensure-native-runtime.mjs; else printf %s config/scripts/ensure-native-runtime.mjs; fi)" --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 "$(if [ -f config/scripts/native-build/node-addons/ensure-native-runtime.mjs ]; then printf %s config/scripts/native-build/node-addons/ensure-native-runtime.mjs; else printf %s config/scripts/ensure-native-runtime.mjs; fi)" --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/release/publication/assert-github-release-is-draft.mjs config/scripts/release/operations/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"
|
||
|
||
# Electron and builder releases fix what this downloads; the lockfile churns far more often.
|
||
- name: Resolve electron-builder download versions
|
||
id: eb-versions
|
||
shell: bash
|
||
run: node -p "const d = require('./package.json').devDependencies; 'versions=electron' + d.electron + '-builder' + d['electron-builder']" >> "$GITHUB_OUTPUT"
|
||
|
||
# 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 }}-${{ steps.eb-versions.outputs.versions }}
|
||
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-${{ steps.eb-versions.outputs.versions }}
|
||
# 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
|
||
RELEASE_JAVASCRIPT_ARTIFACT='config/scripts/release/javascript-artifacts/release-javascript-artifact.mjs'
|
||
if [ ! -f "$RELEASE_JAVASCRIPT_ARTIFACT" ]; then
|
||
RELEASE_JAVASCRIPT_ARTIFACT='config/scripts/release-javascript-artifact.mjs'
|
||
fi
|
||
node "$RELEASE_JAVASCRIPT_ARTIFACT" 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://orca.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/diagnostics/runtime-watchers/runtime-file-watcher-fault-harness.mjs
|
||
ELECTRON_RUN_AS_NODE=1 pnpm exec electron config/scripts/diagnostics/runtime-watchers/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/desktop-distribution/runtime-verification/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/release/operations/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 $(if (Test-Path config/scripts/orcad-distribution/template/packaged-orcad-template.cjs) { "config/scripts/orcad-distribution/template/packaged-orcad-template.cjs" } else { "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/desktop-distribution/nsis/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/desktop-distribution/windows/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/archives/seven-zip/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/release/publication/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/desktop-build/telemetry-contracts/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/release/operations/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/release/operations/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/release/publication/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"
|
||
|
||
# After publish: the draft's asset checks and undraft never see these, and a failure here only
|
||
# leaves the server tarballs missing from an already published release.
|
||
publish-orca-server-artifact:
|
||
needs:
|
||
- cut
|
||
- publish-release
|
||
- orca-server-artifact
|
||
if: ${{ !cancelled() && needs.publish-release.result == 'success' && needs.orca-server-artifact.result == 'success' && needs.orca-server-artifact.outputs.packed == 'true' && needs.cut.outputs.tag != '' }}
|
||
runs-on: ubuntu-latest
|
||
timeout-minutes: 15
|
||
permissions:
|
||
contents: write
|
||
steps:
|
||
- uses: actions/download-artifact@v8
|
||
with:
|
||
pattern: orca-server-linux-*
|
||
path: orca-server
|
||
merge-multiple: true
|
||
- name: Write the checksum manifest and upload
|
||
env:
|
||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||
TAG: ${{ needs.cut.outputs.tag }}
|
||
working-directory: orca-server
|
||
run: |
|
||
set -euo pipefail
|
||
cat orca-server-linux-x64.tar.gz.sha256 orca-server-linux-arm64.tar.gz.sha256 > orca-server-sha256sums.txt
|
||
sha256sum -c orca-server-sha256sums.txt
|
||
gh release upload "$TAG" \
|
||
orca-server-linux-x64.tar.gz \
|
||
orca-server-linux-arm64.tar.gz \
|
||
orca-server-sha256sums.txt \
|
||
--clobber --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
|
||
|
||
release-mirror-r2:
|
||
needs:
|
||
- cut
|
||
- publish-release
|
||
# Why stable-only: orca.dev's mirror link tracks GitHub's latest release, which skips RCs.
|
||
if: ${{ !cancelled() && needs.publish-release.result == 'success' && needs.cut.outputs.tag != '' && !contains(needs.cut.outputs.tag, '-rc.') && startsWith(needs.cut.outputs.tag, 'v') }}
|
||
uses: ./.github/workflows/release-mirror-r2.yml
|
||
with:
|
||
tag: ${{ needs.cut.outputs.tag }}
|
||
secrets: inherit
|
||
|
||
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
|