mirror of
https://github.com/stablyai/orca.git
synced 2026-10-30 16:47:34 +00:00
Reverts #29133 except its daemon async-I/O stall telemetry, so we can measure how often stalls happen on official Node before committing to a patched runtime. The macOS terminal host goes back to the official Node 24.22.1 download and the daemon protocol back to v51; no release or hourly build published v52.
2310 lines
106 KiB
YAML
2310 lines
106 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/publish-complete-draft-releases.mjs
|
||
|
||
- name: Compute next version
|
||
id: version
|
||
# Stop after RC-only draft recovery; stable dispatch still cuts its requested version.
|
||
if: steps.window.outputs.allowed == 'true' && steps.existing.outputs.already_ran != 'true' && !(github.event_name == 'workflow_dispatch' && inputs.dry_run) && !((github.event_name == 'schedule' || inputs.kind == 'rc') && steps.publish_drafts.outputs.published_count != '0' && steps.publish_drafts.outputs.skipped_count == '0')
|
||
env:
|
||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||
KIND: ${{ github.event_name == 'schedule' && 'rc' || inputs.kind }}
|
||
VERSION_SUFFIX: ${{ github.event_name == 'schedule' && '' || inputs.version_suffix }}
|
||
EXPLICIT_VERSION: ${{ github.event_name == 'schedule' && '' || inputs.version }}
|
||
run: |
|
||
set -euo pipefail
|
||
|
||
# Use desktop v<digit> tag shape and numeric semver max, excluding RC/mobile tags.
|
||
# Builder flipped v1.3.22-rc.2’s prerelease flag on 2026-04-27; that flag cannot authorize stable math.
|
||
# 2026-05-04/run25304336767: mobile-v0.0.1 collapsed through NaN to 0.0.1 without the desktop prefix.
|
||
# 2026-06-04: release-list order put v1.4.42 before v1.4.44 and reopened 1.4.43 as rc.0.
|
||
latest_stable="$(node config/scripts/latest-stable-release.mjs)"
|
||
latest_stable="${latest_stable#v}"
|
||
echo "Latest stable: ${latest_stable:-<none>}"
|
||
|
||
# Strip prerelease suffixes before numeric math: Number("1-rc") becomes NaN and silently collapsed
|
||
# to 1, producing v1.3.1-rc.4 on 2026-04-27 when latest_stable was misclassified.
|
||
strip_pre() { echo "${1%%-*}"; }
|
||
|
||
semver_gt() {
|
||
# returns 0 if $1 > $2 by semver rules (ignoring prerelease)
|
||
node -e '
|
||
const a = process.argv[1].split(".").map(Number);
|
||
const b = process.argv[2].split(".").map(Number);
|
||
for (let i = 0; i < 3; i++) {
|
||
if ((a[i]||0) > (b[i]||0)) process.exit(0);
|
||
if ((a[i]||0) < (b[i]||0)) process.exit(1);
|
||
}
|
||
process.exit(1);
|
||
' "$(strip_pre "$1")" "$(strip_pre "$2")"
|
||
}
|
||
|
||
bump() {
|
||
# $1=version, $2=level (patch|minor|major)
|
||
node -e '
|
||
const v = process.argv[1].split(".").map(Number);
|
||
const level = process.argv[2];
|
||
if (level === "major") console.log(`${(v[0]||0)+1}.0.0`);
|
||
else if (level === "minor") console.log(`${v[0]||0}.${(v[1]||0)+1}.0`);
|
||
else console.log(`${v[0]||0}.${v[1]||0}.${(v[2]||0)+1}`);
|
||
' "$(strip_pre "$1")" "$2"
|
||
}
|
||
|
||
highest_rc_for_base() {
|
||
node config/scripts/release-rc-history.mjs "$1"
|
||
}
|
||
|
||
require_valid_version_suffix() {
|
||
# Append rc.N.<identifier> so side builds sort above rc.N but below rc.N+1; clients match the suffix.
|
||
# Reject leading-zero numeric identifiers: npm normalizes rc.4.01 to rc.4.1 while tagging keeps the input.
|
||
if [[ ! "$1" =~ ^(0|[1-9][0-9]*|[0-9A-Za-z]*[A-Za-z][0-9A-Za-z]*)$ ]]; then
|
||
echo "::error::version_suffix (or the trailing .identifier in version) must be alphanumeric with no leading zero on an all-digit identifier, got: $1" >&2
|
||
exit 1
|
||
fi
|
||
}
|
||
|
||
current_package_stable() {
|
||
node -e '
|
||
const { version } = require("./package.json");
|
||
if (/^[0-9]+\.[0-9]+\.[0-9]+$/.test(version)) console.log(version);
|
||
'
|
||
}
|
||
|
||
tag_matches_current_ref() {
|
||
local tag="$1"
|
||
local tag_commit
|
||
local head_commit
|
||
if ! tag_commit="$(git rev-parse "${tag}^{}" 2>/dev/null)"; then
|
||
return 1
|
||
fi
|
||
head_commit="$(git rev-parse HEAD)"
|
||
if [[ "$tag_commit" == "$head_commit" ]]; then
|
||
return 0
|
||
fi
|
||
|
||
local tag_parent
|
||
tag_parent="$(git rev-parse "${tag_commit}^" 2>/dev/null)" || return 1
|
||
[[ "$tag_parent" == "$head_commit" ]]
|
||
}
|
||
|
||
release_draft_state() {
|
||
# Prints: true, false, or missing.
|
||
local tag="$1"
|
||
local state_file="$RUNNER_TEMP/release-state-${tag//[^A-Za-z0-9_.-]/_}"
|
||
if gh release view "$tag" \
|
||
--repo "$GITHUB_REPOSITORY" \
|
||
--json isDraft \
|
||
--jq '.isDraft' >"$state_file" 2>/dev/null; then
|
||
cat "$state_file"
|
||
else
|
||
echo "missing"
|
||
fi
|
||
}
|
||
|
||
recover_unpublished_tag() {
|
||
local tag="$1"
|
||
local reason="$2"
|
||
local release_state
|
||
release_state="$(release_draft_state "$tag")"
|
||
case "$release_state" in
|
||
missing|true)
|
||
if ! tag_matches_current_ref "$tag"; then
|
||
echo "::warning::Tag $tag already exists but was cut from a different release ref ($reason) - cutting the next version instead of reusing stale artifacts."
|
||
return 1
|
||
fi
|
||
echo "::warning::Tag $tag already exists but has no published release ($reason) - recovering by re-dispatching the release build against the existing tag."
|
||
echo "recovered_tag=$tag" >>"$GITHUB_OUTPUT"
|
||
echo "recovered=true" >>"$GITHUB_OUTPUT"
|
||
exit 0
|
||
;;
|
||
false)
|
||
return 1
|
||
;;
|
||
*)
|
||
echo "::error::Unexpected release state for $tag: $release_state" >&2
|
||
exit 1
|
||
;;
|
||
esac
|
||
}
|
||
|
||
# Fresh repo fallback so the math below never divides by zero.
|
||
if [[ -z "$latest_stable" ]]; then
|
||
latest_stable="0.0.0"
|
||
fi
|
||
|
||
package_stable="$(current_package_stable)"
|
||
if [[ -n "$package_stable" ]]; then
|
||
# Deleted releases can regress GitHub’s version list after their bump reached main; package.json floors the current ref.
|
||
if semver_gt "$package_stable" "$latest_stable"; then
|
||
# Explicit versions must skip floor-tag recovery (which can exit successfully with the wrong tag).
|
||
# Still enforce the package floor; recover collisions for the requested tag later.
|
||
if [[ "$KIND" != "rc" && -z "${EXPLICIT_VERSION:-}" ]]; then
|
||
package_tag="v$package_stable"
|
||
if git rev-parse "$package_tag" >/dev/null 2>&1; then
|
||
recover_unpublished_tag "$package_tag" "current ref stable tag is newer than latest published stable" || true
|
||
fi
|
||
fi
|
||
|
||
echo "Stable floor from package.json: $package_stable"
|
||
latest_stable="$package_stable"
|
||
fi
|
||
fi
|
||
|
||
# Manual explicit versions let hotfixes from older refs leapfrog deleted shipped versions (e.g. 1.4.154 → 1.4.155)
|
||
# when published-release math regresses and the ref lacks the newer package floor. Keep updater/tag-collision gates.
|
||
new=""
|
||
if [[ -n "${EXPLICIT_VERSION:-}" ]]; then
|
||
explicit="${EXPLICIT_VERSION#v}"
|
||
# Accept one optional side-branch RC identifier, still strictly above the series head; it grants no rollback exception.
|
||
# Bound rc ordinals to nine digits without leading zeros: Bash -le misreads rc.08 as octal and wraps INTMAX,
|
||
# letting rc.99999999999999999999 pass as 7766279631452241919 and pin highest_rc_for_base at 1e20 forever.
|
||
if [[ ! "$explicit" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-rc\.(0|[1-9][0-9]{0,8})(\.[0-9A-Za-z]+)?)?$ ]]; then
|
||
echo "::error::version must be X.Y.Z, X.Y.Z-rc.N, or X.Y.Z-rc.N.suffix, got: $EXPLICIT_VERSION" >&2
|
||
exit 1
|
||
fi
|
||
# Reuse identifier validation: rc.4.01 matches shape but is invalid semver.
|
||
if [[ "$explicit" == *-rc.*.* ]]; then
|
||
require_valid_version_suffix "${explicit##*.}"
|
||
fi
|
||
# Explicit stable lines must strictly exceed published stable, ignoring prerelease identifiers.
|
||
if ! semver_gt "$explicit" "$latest_stable"; then
|
||
echo "::error::Refusing explicit version $explicit: not greater than latest stable $latest_stable." >&2
|
||
exit 1
|
||
fi
|
||
# Stable-line comparison alone admits rc.0 after rc.3 shipped. Require the override to advance its
|
||
# own series too, avoiding the rc.4 regression that orphaned live daemons.
|
||
if [[ "$explicit" == *-rc.* ]]; then
|
||
explicit_base="${explicit%%-*}"
|
||
explicit_rc="${explicit#*-rc.}"
|
||
explicit_rc="${explicit_rc%%.*}"
|
||
highest_explicit_rc="$(highest_rc_for_base "$explicit_base")"
|
||
if [[ -n "$highest_explicit_rc" && "$explicit_rc" -le "$highest_explicit_rc" ]]; then
|
||
# kind=rc resumes only latest stable’s next patch; minor/major RC series require explicit overrides,
|
||
# or the suggested remedy would cut an unrelated release.
|
||
echo "::error::Refusing explicit version $explicit: rc.$explicit_rc is not above rc.$highest_explicit_rc, the highest already cut for $explicit_base. Request rc.$((highest_explicit_rc + 1)) or higher. If you are resuming an unpublished tag and $explicit_base is the next patch after latest stable $latest_stable, dispatch kind=rc instead, which recovers that tag when it was cut from the ref you dispatch; otherwise cut rc.$((highest_explicit_rc + 1)) and leave the unpublished tag alone." >&2
|
||
exit 1
|
||
fi
|
||
fi
|
||
new="$explicit"
|
||
# The override bypasses kind-path suffixing: append here too, only to bare RCs, avoiding invalid
|
||
# stable X.Y.Z.perf or repeated rc.N.perf.perf suffixes.
|
||
if [[ -n "${VERSION_SUFFIX:-}" ]]; then
|
||
# Same bounded rc pattern as the shape check above, so the two
|
||
# cannot drift apart under a later edit.
|
||
if [[ ! "$explicit" =~ ^[0-9]+\.[0-9]+\.[0-9]+-rc\.(0|[1-9][0-9]{0,8})$ ]]; then
|
||
echo "::error::version_suffix applies only to a bare X.Y.Z-rc.N version, got: $explicit" >&2
|
||
exit 1
|
||
fi
|
||
require_valid_version_suffix "$VERSION_SUFFIX"
|
||
new="${new}.${VERSION_SUFFIX}"
|
||
fi
|
||
echo "Explicit version override: $new"
|
||
fi
|
||
|
||
if [[ -z "$new" ]]; then
|
||
case "$KIND" in
|
||
rc)
|
||
# RCs target published stable + next patch. Highest-tag continuation reopened shipped series
|
||
# (v1.3.21-rc.7 after v1.3.21 stable); cut minor/major stable first to start those RC bases.
|
||
base="$(bump "$latest_stable" patch)"
|
||
highest_rc="$(highest_rc_for_base "$base")"
|
||
if [[ -z "$highest_rc" ]]; then
|
||
new="${base}-rc.0"
|
||
else
|
||
existing_rc_tag="v${base}-rc.${highest_rc}"
|
||
# Resume highest draft/missing RC only when cut from this ref; stale attempts advance to rc.N+1.
|
||
if git rev-parse "$existing_rc_tag" >/dev/null 2>&1; then
|
||
recover_unpublished_tag "$existing_rc_tag" "latest RC in series" || true
|
||
fi
|
||
new="${base}-rc.$((highest_rc + 1))"
|
||
fi
|
||
if [[ -n "${VERSION_SUFFIX:-}" ]]; then
|
||
require_valid_version_suffix "$VERSION_SUFFIX"
|
||
new="${new}.${VERSION_SUFFIX}"
|
||
fi
|
||
;;
|
||
patch|minor|major)
|
||
new="$(bump "$latest_stable" "$KIND")"
|
||
# Updater-safety gate: stable must strictly increase.
|
||
if ! semver_gt "$new" "$latest_stable"; then
|
||
echo "::error::Refusing to cut $KIND $new: not greater than latest stable $latest_stable." >&2
|
||
exit 1
|
||
fi
|
||
|
||
# If an old orphan stable tag cannot be recovered for this ref, advance rather than wedge future patch cuts.
|
||
for _ in {1..100}; do
|
||
candidate_tag="v$new"
|
||
if ! git rev-parse "$candidate_tag" >/dev/null 2>&1; then
|
||
break
|
||
fi
|
||
|
||
candidate_release_state="$(release_draft_state "$candidate_tag")"
|
||
case "$candidate_release_state" in
|
||
missing|true)
|
||
recover_unpublished_tag "$candidate_tag" "tag collision" || true
|
||
new="$(bump "$new" "$KIND")"
|
||
;;
|
||
false)
|
||
echo "::error::Tag $candidate_tag already exists with a published release. Refusing to skip over a shipped version." >&2
|
||
exit 1
|
||
;;
|
||
*)
|
||
echo "::error::Unexpected release state for $candidate_tag: $candidate_release_state" >&2
|
||
exit 1
|
||
;;
|
||
esac
|
||
done
|
||
;;
|
||
*)
|
||
echo "::error::Unknown kind: $KIND" >&2
|
||
exit 1
|
||
;;
|
||
esac
|
||
fi
|
||
|
||
# Recover existing draft/absent releases by emitting the pushed tag and rerunning builds without bump/commit/push.
|
||
# 2026-05-01/run25237882049: cancellation after pushing v1.3.26 wedged every patch cut on that orphan tag
|
||
# until manual release dispatch. A tag plus published release is a real collision requiring operator attention.
|
||
if git rev-parse "v$new" >/dev/null 2>&1; then
|
||
recover_unpublished_tag "v$new" "tag collision" || {
|
||
echo "::error::Tag v$new already exists and cannot be recovered for this ref. Refusing to re-cut over an existing version." >&2
|
||
exit 1
|
||
}
|
||
fi
|
||
|
||
echo "version=$new" >>"$GITHUB_OUTPUT"
|
||
echo "Next version: $new"
|
||
|
||
- name: Bump package.json and tag
|
||
id: tag
|
||
if: steps.version.outputs.version != '' && steps.version.outputs.recovered != 'true'
|
||
env:
|
||
EVENT_NAME: ${{ github.event_name }}
|
||
SLOT: ${{ steps.slot.outputs.value }}
|
||
VERSION: ${{ steps.version.outputs.version }}
|
||
run: |
|
||
set -euo pipefail
|
||
# Control commit message/tag ourselves via npm version --no-git-tag-version, avoiding npm’s prefix/lifecycle assumptions.
|
||
npm version "$VERSION" --no-git-tag-version --allow-same-version
|
||
# Tags must describe their bundled skill bytes; verify freshness with Node built-ins before install.
|
||
if ! node config/scripts/generate-skill-bundle-manifest.mjs; then
|
||
echo "::error::Refusing to tag v$VERSION: the committed skill manifest does not match skills/. Land a regeneration on main, then re-run the cut." >&2
|
||
exit 1
|
||
fi
|
||
git add package.json
|
||
commit_message="release: v$VERSION"
|
||
if [[ "$EVENT_NAME" == "schedule" ]]; then
|
||
commit_message="$commit_message [rc-slot:$SLOT]"
|
||
fi
|
||
if git diff --cached --quiet; then
|
||
# A failed cut may already have bumped main; re-cut still needs a fresh taggable commit.
|
||
git commit --allow-empty -m "$commit_message"
|
||
else
|
||
git commit -m "$commit_message"
|
||
fi
|
||
# Validate the committed tree, not staged intent: git commit -a/-i/--only/pathspec can bypass the index.
|
||
# Literal -F matching cannot accidentally admit packageXjson; -m --first-parent makes merge commits visible
|
||
# to diff-tree so the allowlist cannot silently pass an empty merge diff.
|
||
committed="$(git diff-tree --no-commit-id --name-only -r -m --first-parent HEAD |
|
||
grep -vxF -e 'package.json' || true)"
|
||
if [[ -n "$committed" ]]; then
|
||
echo "::error::Release commit carries unexpected paths: $(echo "$committed" | tr '\n' ' ')Only package.json may ship in a version commit." >&2
|
||
exit 1
|
||
fi
|
||
git tag -a "v$VERSION" -m "v$VERSION"
|
||
echo "tag=v$VERSION" >>"$GITHUB_OUTPUT"
|
||
echo "sha=$(git rev-parse HEAD)" >>"$GITHUB_OUTPUT"
|
||
|
||
- name: Push tag
|
||
if: steps.tag.outputs.tag != ''
|
||
env:
|
||
PUSH_MAIN: ${{ steps.resolve.outputs.push_main }}
|
||
TAG: ${{ steps.tag.outputs.tag }}
|
||
run: |
|
||
set -euo pipefail
|
||
if [[ "$PUSH_MAIN" == "true" ]]; then
|
||
push_log="$RUNNER_TEMP/release-push-main.log"
|
||
if git push origin "HEAD:refs/heads/main" >"$push_log" 2>&1; then
|
||
cat "$push_log"
|
||
elif grep -Fq 'GH013' "$push_log" && grep -Fq 'Changes must be made through a pull request.' "$push_log"; then
|
||
cat "$push_log"
|
||
echo "::notice::Main requires pull requests; leaving its version unchanged and publishing only $TAG."
|
||
else
|
||
cat "$push_log"
|
||
exit 1
|
||
fi
|
||
fi
|
||
git push origin "$TAG"
|
||
|
||
# Why: a patch cut from a base older than #24155 has no orcad template to build or ship.
|
||
- name: Detect whether the tag ships the orcad template
|
||
id: orcad-template-support
|
||
if: steps.tag.outputs.tag != '' || steps.version.outputs.recovered_tag != ''
|
||
env:
|
||
TAG: ${{ steps.tag.outputs.tag || steps.version.outputs.recovered_tag }}
|
||
run: |
|
||
set -euo pipefail
|
||
if git cat-file -e "refs/tags/${TAG}^{commit}:config/scripts/packaged-orcad-template.cjs" 2>/dev/null; then
|
||
echo "ships=true" >>"$GITHUB_OUTPUT"
|
||
else
|
||
echo "ships=false" >>"$GITHUB_OUTPUT"
|
||
echo "::notice::$TAG predates the orcad template; skipping its build and download."
|
||
fi
|
||
|
||
- name: Release E2E signal summary
|
||
if: always()
|
||
run: |
|
||
{
|
||
echo "## Release E2E Signal"
|
||
echo ""
|
||
echo "- Platform golden E2E is release-blocking: terminal rendering, restrictive-umask profile writes, source control, and agent TUI launch on Linux/macOS, plus fresh startup and source control on Windows."
|
||
echo "- Exception: every golden except terminal rendering runs with \`--if-present\`, so it is skipped (not failed) on older tags that predate its script."
|
||
echo "- Full E2E runs separately after publication and cannot change the release result."
|
||
echo "- Terminal rendering release evidence is diagnostic/non-blocking."
|
||
echo ""
|
||
echo "Publishing behavior is controlled by the existing job dependencies; this summary does not change release gating."
|
||
} >> "$GITHUB_STEP_SUMMARY"
|
||
|
||
create-release:
|
||
needs: cut
|
||
if: needs.cut.outputs.should_release == 'true'
|
||
runs-on: ubuntu-latest
|
||
permissions:
|
||
contents: write
|
||
steps:
|
||
- name: Checkout
|
||
uses: actions/checkout@v6
|
||
with:
|
||
ref: refs/tags/${{ needs.cut.outputs.tag }}
|
||
|
||
- name: Restore draft-release scripts from the workflow ref
|
||
env:
|
||
WORKFLOW_SHA: ${{ github.workflow_sha }}
|
||
run: |
|
||
set -euo pipefail
|
||
git fetch --no-tags --depth=1 origin "$WORKFLOW_SHA"
|
||
git checkout "$WORKFLOW_SHA" -- \
|
||
config/scripts/create-draft-release.mjs \
|
||
config/scripts/assert-github-release-is-draft.mjs
|
||
|
||
- name: Create draft release with bounded generated notes
|
||
env:
|
||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||
TAG: ${{ needs.cut.outputs.tag }}
|
||
run: node config/scripts/create-draft-release.mjs "$TAG"
|
||
|
||
dependency-security:
|
||
needs: cut
|
||
if: needs.cut.outputs.should_release == 'true' && !contains(needs.cut.outputs.tag, '-')
|
||
uses: ./.github/workflows/dependency-security.yml
|
||
with:
|
||
ref: refs/tags/${{ needs.cut.outputs.tag }}
|
||
runner: ubuntu-latest
|
||
secrets:
|
||
SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}
|
||
|
||
terminal-rendering-golden:
|
||
needs: cut
|
||
if: needs.cut.outputs.should_release == 'true'
|
||
name: golden e2e ${{ matrix.platform }}
|
||
runs-on: ${{ matrix.os }}
|
||
timeout-minutes: 30
|
||
env:
|
||
NODE_OPTIONS: --max-old-space-size=4096
|
||
strategy:
|
||
fail-fast: false
|
||
matrix:
|
||
include:
|
||
- os: ubuntu-latest
|
||
platform: linux
|
||
- os: macos-15
|
||
platform: mac
|
||
# Windows terminal rendering remains flaky; keep its blocking signal
|
||
# scoped to the fresh-profile startup regression from #14130.
|
||
- os: windows-2022
|
||
platform: windows
|
||
|
||
steps:
|
||
- name: Checkout
|
||
uses: actions/checkout@v6
|
||
with:
|
||
ref: refs/tags/${{ needs.cut.outputs.tag }}
|
||
|
||
- name: Restore golden test harness from the workflow ref
|
||
shell: bash
|
||
env:
|
||
WORKFLOW_SHA: ${{ github.workflow_sha }}
|
||
run: |
|
||
git fetch --no-tags --depth=1 origin "$WORKFLOW_SHA"
|
||
git checkout "$WORKFLOW_SHA" -- \
|
||
tests/e2e/golden-source-control-open-diff.spec.ts \
|
||
tests/e2e/golden-terminal-file-link.spec.ts \
|
||
tests/e2e/golden-worktree-create-switch.spec.ts
|
||
|
||
- name: Install native build tools
|
||
if: runner.os == 'Linux'
|
||
run: sudo apt-get update && sudo apt-get install -y build-essential python3 xvfb
|
||
|
||
- name: Setup pnpm
|
||
uses: pnpm/setup@v2
|
||
with:
|
||
install: false
|
||
|
||
- name: Setup Node.js
|
||
uses: actions/setup-node@v6
|
||
with:
|
||
node-version-file: package.json
|
||
check-latest: true
|
||
cache: pnpm
|
||
|
||
# Why: Linux terminal golden E2E uses the same native install path as
|
||
# release CI, which needs pnpm to bypass its non-executable gyp_main.py.
|
||
- name: Use external node-gyp to avoid pnpm's bundled copy (Linux only)
|
||
if: runner.os == 'Linux'
|
||
run: |
|
||
npm install -g node-gyp@11.5.0
|
||
echo "npm_config_node_gyp=$(npm root -g)/node-gyp/bin/node-gyp.js" >> "$GITHUB_ENV"
|
||
|
||
# Retry node-gyp header fetch ECONNRESET so transient install failures cannot fail the whole release.
|
||
- name: Install dependencies
|
||
uses: nick-fields/retry@v4
|
||
with:
|
||
timeout_minutes: 10
|
||
max_attempts: 3
|
||
retry_wait_seconds: 30
|
||
command: pnpm install --frozen-lockfile
|
||
|
||
- name: Build Electron app for platform golden
|
||
run: npx electron-vite build --mode e2e
|
||
|
||
# Use --if-present for golden scripts absent from older hotfix/cut tags, despite newer dispatch YAML.
|
||
- name: Run terminal rendering golden on Linux
|
||
if: runner.os == 'Linux'
|
||
run: |
|
||
xvfb-run --auto-servernum env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run --if-present test:e2e:workspace-session-golden
|
||
xvfb-run --auto-servernum env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run test:e2e:terminal-rendering-golden
|
||
xvfb-run --auto-servernum env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run --if-present test:e2e:posix-profile-index-golden
|
||
|
||
- name: Run source-control golden on Linux
|
||
if: runner.os == 'Linux'
|
||
run: xvfb-run --auto-servernum env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run --if-present test:e2e:source-control-golden
|
||
|
||
- name: Run terminal rendering golden on macOS
|
||
if: runner.os == 'macOS'
|
||
run: |
|
||
env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run --if-present test:e2e:workspace-session-golden
|
||
env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run test:e2e:terminal-rendering-golden
|
||
env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run --if-present test:e2e:posix-profile-index-golden
|
||
|
||
- name: Run agent TUI golden on Linux
|
||
if: runner.os == 'Linux'
|
||
run: xvfb-run --auto-servernum env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run --if-present test:e2e:agent-tui-golden
|
||
|
||
- name: Run agent TUI golden on macOS
|
||
if: runner.os == 'macOS'
|
||
run: env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run --if-present test:e2e:agent-tui-golden
|
||
|
||
- name: Run source-control golden on macOS
|
||
if: runner.os == 'macOS'
|
||
run: env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run --if-present test:e2e:source-control-golden
|
||
|
||
- name: Run fresh-startup golden on Windows
|
||
if: runner.os == 'Windows'
|
||
shell: pwsh
|
||
run: |
|
||
$env:SKIP_BUILD = '1'
|
||
$env:ORCA_E2E_FORWARD_APP_LOGS = '1'
|
||
pnpm run --if-present test:e2e:windows-fresh-startup-golden
|
||
|
||
- name: Upload Playwright traces
|
||
if: failure()
|
||
uses: actions/upload-artifact@v7
|
||
with:
|
||
name: golden-e2e-${{ matrix.platform }}-playwright-traces
|
||
path: test-results/
|
||
retention-days: 7
|
||
if-no-files-found: ignore
|
||
|
||
skill-sharing-release-gate:
|
||
needs: cut
|
||
if: needs.cut.outputs.should_release == 'true'
|
||
name: skill sharing release gate ${{ matrix.platform }}
|
||
runs-on: ${{ matrix.os }}
|
||
# The full suite is release-blocking on macOS. Windows still produces the
|
||
# same evidence, but intermittent filesystem contention cannot block signing.
|
||
continue-on-error: ${{ matrix.platform == 'windows' }}
|
||
timeout-minutes: 20
|
||
strategy:
|
||
fail-fast: false
|
||
matrix:
|
||
include:
|
||
- os: macos-15
|
||
platform: mac
|
||
- os: windows-2022
|
||
platform: windows
|
||
|
||
steps:
|
||
- name: Checkout
|
||
uses: actions/checkout@v6
|
||
with:
|
||
ref: refs/tags/${{ needs.cut.outputs.tag }}
|
||
|
||
- name: Restore skill-sharing test harness from the workflow ref
|
||
shell: bash
|
||
env:
|
||
WORKFLOW_SHA: ${{ github.workflow_sha }}
|
||
run: |
|
||
git fetch --no-tags --depth=1 origin "$WORKFLOW_SHA"
|
||
git checkout "$WORKFLOW_SHA" -- \
|
||
src/main/skills/skill-provider-runtime-roots.test.ts
|
||
|
||
- uses: ./.github/actions/install-node-dependencies
|
||
with:
|
||
native-runtime: node
|
||
|
||
- name: Install Electron package binary for tests
|
||
run: node config/scripts/install-electron-package-binary.mjs
|
||
|
||
- name: Run skill package, transaction, and compatibility suites
|
||
env:
|
||
ORCA_REAL_PROCESS_SKILL_TEST: '1'
|
||
ORCA_REAL_WINDOWS_SKILL_TEST: ${{ runner.os == 'Windows' && '1' || '0' }}
|
||
run: pnpm test:skill-sharing:release --reporter=json --outputFile=skill-sharing-release-results.json
|
||
|
||
- name: Archive bounded skill-sharing results
|
||
if: always()
|
||
uses: actions/upload-artifact@v7
|
||
with:
|
||
name: skill-sharing-release-${{ matrix.platform }}
|
||
path: skill-sharing-release-results.json
|
||
retention-days: 14
|
||
if-no-files-found: error
|
||
|
||
skill-sharing-linux-floor-release-gate:
|
||
needs: cut
|
||
if: needs.cut.outputs.should_release == 'true'
|
||
name: skill sharing release gate linux-glibc-2.31
|
||
runs-on: ubuntu-latest
|
||
timeout-minutes: 20
|
||
container: ubuntu:20.04
|
||
|
||
steps:
|
||
- name: Install Ubuntu 20.04 prerequisites
|
||
run: apt-get update && apt-get install -y build-essential ca-certificates git python3 unzip
|
||
|
||
- name: Checkout
|
||
uses: actions/checkout@v6
|
||
with:
|
||
ref: refs/tags/${{ needs.cut.outputs.tag }}
|
||
|
||
- name: Trust the checked-out workspace in the job container
|
||
run: git config --global --add safe.directory "$GITHUB_WORKSPACE"
|
||
|
||
- name: Restore skill-sharing test harness from the workflow ref
|
||
shell: bash
|
||
env:
|
||
WORKFLOW_SHA: ${{ github.workflow_sha }}
|
||
run: |
|
||
git fetch --no-tags --depth=1 origin "$WORKFLOW_SHA"
|
||
git checkout "$WORKFLOW_SHA" -- \
|
||
src/main/skills/skill-provider-runtime-roots.test.ts
|
||
|
||
- uses: ./.github/actions/install-node-dependencies
|
||
with:
|
||
native-runtime: node
|
||
|
||
- name: Install Electron package binary for tests
|
||
run: node config/scripts/install-electron-package-binary.mjs
|
||
|
||
- name: Run skill package, transaction, and compatibility suites
|
||
env:
|
||
ORCA_REAL_PROCESS_SKILL_TEST: '1'
|
||
run: pnpm test:skill-sharing:release --reporter=json --outputFile=skill-sharing-release-results.json
|
||
|
||
- name: Archive bounded skill-sharing results
|
||
if: always()
|
||
uses: actions/upload-artifact@v7
|
||
with:
|
||
name: skill-sharing-release-linux-glibc-2.31
|
||
path: skill-sharing-release-results.json
|
||
retention-days: 14
|
||
if-no-files-found: error
|
||
|
||
# Why: these broader terminal rendering repros are useful release evidence,
|
||
# but they include heavier app-like flows and must not block publishing.
|
||
terminal-rendering-release-evidence:
|
||
needs: cut
|
||
if: needs.cut.outputs.should_release == 'true'
|
||
continue-on-error: true
|
||
name: terminal rendering release evidence ${{ matrix.platform }}
|
||
runs-on: ${{ matrix.os }}
|
||
timeout-minutes: 35
|
||
env:
|
||
NODE_OPTIONS: --max-old-space-size=4096
|
||
strategy:
|
||
fail-fast: false
|
||
matrix:
|
||
include:
|
||
- os: ubuntu-latest
|
||
platform: linux
|
||
- os: macos-15
|
||
platform: mac
|
||
# Why: Windows release evidence currently fails on CI runner PTY
|
||
# readiness before reaching the rendering assertions.
|
||
# - os: windows-latest
|
||
# platform: windows
|
||
|
||
steps:
|
||
- name: Checkout
|
||
uses: actions/checkout@v6
|
||
with:
|
||
ref: refs/tags/${{ needs.cut.outputs.tag }}
|
||
|
||
- name: Install native build tools
|
||
if: runner.os == 'Linux'
|
||
run: sudo apt-get update && sudo apt-get install -y build-essential python3 xvfb
|
||
|
||
- name: Setup pnpm
|
||
uses: pnpm/setup@v2
|
||
with:
|
||
install: false
|
||
|
||
- name: Setup Node.js
|
||
uses: actions/setup-node@v6
|
||
with:
|
||
node-version-file: package.json
|
||
check-latest: true
|
||
cache: pnpm
|
||
|
||
# Why: keep the non-blocking evidence lane on the same Linux native
|
||
# install path as the blocking golden and release build jobs.
|
||
- name: Use external node-gyp to avoid pnpm's bundled copy (Linux only)
|
||
if: runner.os == 'Linux'
|
||
run: |
|
||
npm install -g node-gyp@11.5.0
|
||
echo "npm_config_node_gyp=$(npm root -g)/node-gyp/bin/node-gyp.js" >> "$GITHUB_ENV"
|
||
|
||
# Same node-gyp header fetch as the blocking golden gate above.
|
||
- name: Install dependencies
|
||
uses: nick-fields/retry@v4
|
||
with:
|
||
timeout_minutes: 10
|
||
max_attempts: 3
|
||
retry_wait_seconds: 30
|
||
command: pnpm install --frozen-lockfile
|
||
|
||
- name: Build Electron app for terminal rendering evidence
|
||
run: npx electron-vite build --mode e2e
|
||
|
||
- name: Run terminal rendering evidence on Linux
|
||
if: runner.os == 'Linux'
|
||
run: xvfb-run --auto-servernum env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run test:e2e:terminal-rendering-release-evidence
|
||
|
||
- name: Run terminal rendering evidence on macOS
|
||
if: runner.os == 'macOS'
|
||
run: env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run test:e2e:terminal-rendering-release-evidence
|
||
|
||
- name: Run terminal rendering evidence on Windows
|
||
if: runner.os == 'Windows'
|
||
shell: pwsh
|
||
run: |
|
||
$env:SKIP_BUILD = '1'
|
||
$env:ORCA_E2E_FORWARD_APP_LOGS = '1'
|
||
pnpm run test:e2e:terminal-rendering-release-evidence
|
||
|
||
- name: Upload Playwright traces
|
||
if: failure()
|
||
uses: actions/upload-artifact@v7
|
||
with:
|
||
name: terminal-rendering-release-evidence-${{ matrix.platform }}-playwright-traces
|
||
path: test-results/
|
||
retention-days: 7
|
||
if-no-files-found: ignore
|
||
|
||
# Build every target’s qualified orcad slot at the tag beside release gates; unsigned template consumes no signing quota.
|
||
orcad-template:
|
||
needs: cut
|
||
if: needs.cut.outputs.should_release == 'true' && needs.cut.outputs.ships_orcad_template == 'true'
|
||
permissions:
|
||
contents: read
|
||
actions: read
|
||
uses: ./.github/workflows/qualified-orcad-template.yml
|
||
with:
|
||
ref: refs/tags/${{ needs.cut.outputs.tag }}
|
||
fast_runners: false
|
||
|
||
# Gate SignPath artifact requests behind blocking tests to avoid approvals for unpublishable builds.
|
||
release-preflight:
|
||
needs:
|
||
- cut
|
||
- terminal-rendering-golden
|
||
- skill-sharing-release-gate
|
||
- skill-sharing-linux-floor-release-gate
|
||
if: >-
|
||
always() &&
|
||
needs.cut.outputs.should_release == 'true' &&
|
||
needs.terminal-rendering-golden.result == 'success' &&
|
||
needs.skill-sharing-release-gate.result == 'success' &&
|
||
needs.skill-sharing-linux-floor-release-gate.result == 'success'
|
||
runs-on: ubuntu-latest
|
||
permissions:
|
||
contents: read
|
||
steps:
|
||
- name: Confirm blocking release gates passed
|
||
run: echo "All blocking release gates passed; artifact builds may start."
|
||
|
||
# Compilation can overlap the release gates; signing remains behind release-preflight.
|
||
release-javascript:
|
||
needs: cut
|
||
if: needs.cut.outputs.should_release == 'true'
|
||
permissions:
|
||
contents: read
|
||
uses: ./.github/workflows/release-javascript.yml
|
||
with:
|
||
ref: refs/tags/${{ needs.cut.outputs.tag }}
|
||
runner: ubuntu-latest
|
||
secrets:
|
||
ORCA_POSTHOG_WRITE_KEY: ${{ secrets.ORCA_POSTHOG_WRITE_KEY }}
|
||
|
||
build:
|
||
needs:
|
||
- cut
|
||
- create-release
|
||
- orcad-template
|
||
- release-preflight
|
||
- release-javascript
|
||
- build-mac
|
||
# Why not the implicit success(): a tag without the orcad template skips that job on purpose.
|
||
if: >-
|
||
!cancelled() &&
|
||
needs.cut.result == 'success' &&
|
||
needs.cut.outputs.should_release == 'true' &&
|
||
needs.create-release.result == 'success' &&
|
||
needs.release-preflight.result == 'success' &&
|
||
needs.release-javascript.result == 'success' &&
|
||
needs.build-mac.result == 'success' &&
|
||
(needs.orcad-template.result == 'success' ||
|
||
(needs.orcad-template.result == 'skipped' && needs.cut.outputs.ships_orcad_template == 'false'))
|
||
env:
|
||
# beforePack and afterPack fail the package when the template is absent.
|
||
ORCA_REQUIRE_ORCAD_TEMPLATE: '1'
|
||
strategy:
|
||
fail-fast: false
|
||
matrix:
|
||
include:
|
||
# Why: windows-latest moved to the Windows 2025 / VS 2026 image before
|
||
# node-gyp could detect VS 18, breaking native dependency install.
|
||
- os: windows-2022
|
||
platform: win
|
||
release_command: 'node config/scripts/ensure-native-runtime.mjs --runtime=electron; if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }; pnpm exec electron-builder --config config/electron-builder.config.cjs --win --publish never'
|
||
eb_cache_path: |
|
||
~\AppData\Local\electron\Cache
|
||
~\AppData\Local\electron-builder\Cache
|
||
- os: ubuntu-latest
|
||
platform: linux-x64
|
||
release_command: node config/scripts/ensure-native-runtime.mjs --runtime=electron && pnpm exec electron-builder --config config/electron-builder.config.cjs --linux AppImage deb rpm --x64 --publish always -c.publish.releaseType=draft
|
||
unpacked_dir: dist/linux-unpacked
|
||
eb_cache_path: |
|
||
~/.cache/electron
|
||
~/.cache/electron-builder
|
||
- os: ubuntu-24.04-arm
|
||
platform: linux-arm64
|
||
release_command: node config/scripts/ensure-native-runtime.mjs --runtime=electron && ORCA_LINUX_ARM64_RELEASE=1 pnpm exec electron-builder --config config/electron-builder.config.cjs --linux AppImage deb rpm --arm64 --publish always -c.publish.releaseType=draft
|
||
unpacked_dir: dist/linux-arm64-unpacked
|
||
eb_cache_path: |
|
||
~/.cache/electron
|
||
~/.cache/electron-builder
|
||
|
||
runs-on: ${{ matrix.os }}
|
||
# Fit 1h inner+4h installer SignPath waits and build under hosted runner’s 6h cap.
|
||
timeout-minutes: 360
|
||
|
||
permissions:
|
||
actions: read
|
||
contents: write
|
||
|
||
steps:
|
||
- name: Checkout
|
||
uses: actions/checkout@v6
|
||
with:
|
||
ref: refs/tags/${{ needs.cut.outputs.tag }}
|
||
|
||
# GitHub reruns also resume jobs skipped behind a failed gate. Never
|
||
# recreate Windows signing requests on a rerun; reuse the assets from the
|
||
# original attempt and require a fresh dispatch if they are missing.
|
||
- name: Skip Windows artifact rebuild on rerun
|
||
if: matrix.platform == 'win' && github.run_attempt != 1
|
||
shell: bash
|
||
run: |
|
||
echo "Windows artifact/signing steps are disabled on reruns (attempt $GITHUB_RUN_ATTEMPT)."
|
||
echo "Existing signed release assets must be reused; dispatch a fresh release only when a rebuild is required." >> "$GITHUB_STEP_SUMMARY"
|
||
|
||
# Restore composite actions from workflow commit: uses:./ resolves checked-out tag, which may predate mobile/dependency actions.
|
||
- name: Restore draft-publish scripts from the workflow ref
|
||
# Restore current workflow helper scripts when old cut tags still publish live and lack re-draft support.
|
||
shell: bash
|
||
env:
|
||
WORKFLOW_SHA: ${{ github.workflow_sha }}
|
||
run: |
|
||
set -euo pipefail
|
||
git fetch --no-tags --depth=1 origin "$WORKFLOW_SHA"
|
||
git checkout "$WORKFLOW_SHA" -- config/scripts/assert-github-release-is-draft.mjs config/scripts/run-release-mac-build-workflow.mjs
|
||
|
||
- name: Restore composite actions from the workflow ref
|
||
shell: bash
|
||
env:
|
||
WORKFLOW_SHA: ${{ github.workflow_sha }}
|
||
PLATFORM: ${{ matrix.platform }}
|
||
run: |
|
||
set -euo pipefail
|
||
required=(.github/actions/install-mobile-dependencies/action.yml)
|
||
if [ "$PLATFORM" = win ] && [ "$GITHUB_RUN_ATTEMPT" = 1 ]; then
|
||
required+=(.github/actions/install-signpath-module/action.yml)
|
||
fi
|
||
missing=()
|
||
for action_path in "${required[@]}"; do
|
||
[ -f "$action_path" ] || missing+=("$action_path")
|
||
done
|
||
if [ "${#missing[@]}" -eq 0 ]; then
|
||
echo "Composite actions already present at the cut ref."
|
||
exit 0
|
||
fi
|
||
echo "Cut ref predates ${missing[*]}; restoring from $WORKFLOW_SHA."
|
||
git fetch --no-tags --depth=1 origin "$WORKFLOW_SHA"
|
||
git checkout "$WORKFLOW_SHA" -- .github/actions
|
||
for action_path in "${required[@]}"; do
|
||
test -f "$action_path"
|
||
done
|
||
|
||
# pnpm must be on PATH before setup-node so setup-node can locate the store for caching.
|
||
- name: Setup pnpm
|
||
uses: pnpm/setup@v2
|
||
with:
|
||
install: false
|
||
|
||
- name: Setup Node.js
|
||
uses: actions/setup-node@v6
|
||
with:
|
||
node-version-file: package.json
|
||
check-latest: true
|
||
cache: pnpm
|
||
cache-dependency-path: |
|
||
pnpm-lock.yaml
|
||
mobile/pnpm-lock.yaml
|
||
|
||
# Override non-executable pnpm node-gyp only on Linux (run 25081763129); runner.os keeps future Linux entries covered.
|
||
- name: Use external node-gyp to avoid pnpm's bundled copy (Linux only)
|
||
if: runner.os == 'Linux'
|
||
run: |
|
||
npm install -g node-gyp@11.5.0
|
||
echo "npm_config_node_gyp=$(npm root -g)/node-gyp/bin/node-gyp.js" >> "$GITHUB_ENV"
|
||
|
||
# Cache Electron and builder downloads (winCodeSign/NSIS/Squirrel/AppImage), saving ~30–90s per job.
|
||
- name: Cache electron-builder downloads
|
||
uses: actions/cache@v5
|
||
with:
|
||
path: ${{ matrix.eb_cache_path }}
|
||
key: electron-builder-${{ matrix.platform }}-${{ hashFiles('pnpm-lock.yaml') }}
|
||
restore-keys: |
|
||
electron-builder-${{ matrix.platform }}-
|
||
|
||
# PRs cache tools separately from Electron; identical paths preserve their cache version.
|
||
- name: Seed shared Linux packaging downloads
|
||
if: matrix.platform == 'linux-x64' && github.ref == 'refs/heads/main'
|
||
uses: actions/cache@v5
|
||
with:
|
||
path: ~/.cache/electron-builder
|
||
key: electron-builder-linux-${{ hashFiles('pnpm-lock.yaml') }}
|
||
# The release cache above restores the same tools, so this only needs to save on a miss.
|
||
lookup-only: true
|
||
|
||
# Retry transient Electron CDN 504s during install; keep host-scoped native dependencies for this platform’s package.
|
||
- name: Install dependencies
|
||
uses: nick-fields/retry@v4
|
||
with:
|
||
timeout_minutes: 10
|
||
max_attempts: 3
|
||
retry_wait_seconds: 30
|
||
command: pnpm install --frozen-lockfile
|
||
|
||
# Build required out/mobile-web from mobile’s React Native/Expo dependencies.
|
||
- uses: ./.github/actions/install-mobile-dependencies
|
||
|
||
# Install AT-SPI runtime bindings for Linux provider verification and rpm for builder’s fpm backend; stock Ubuntu lacks them.
|
||
- name: Install Linux computer-use provider dependencies
|
||
if: runner.os == 'Linux'
|
||
run: sudo apt-get update && sudo apt-get install -y python3-gi gir1.2-atspi-2.0 at-spi2-core xclip xdotool rpm
|
||
|
||
# Derive literal stable/rc telemetry identity from strict tag shape; refuse unclassified builds rather than ship silent null identity (#1385 follow-up).
|
||
- name: Classify release tag for telemetry build identity
|
||
id: tag-classify
|
||
shell: bash
|
||
env:
|
||
TAG: ${{ needs.cut.outputs.tag }}
|
||
run: |
|
||
set -euo pipefail
|
||
# Suffixed vX.Y.Z-rc.N.perf builds keep the same rc-channel telemetry identity.
|
||
if [[ "$TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+-rc\.[0-9]+(\.[0-9A-Za-z]+)?$ ]]; then
|
||
identity=rc
|
||
elif [[ "$TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
|
||
identity=stable
|
||
else
|
||
echo "::error::Tag $TAG does not match stable or rc pattern; refusing to build official artifact"
|
||
exit 1
|
||
fi
|
||
echo "identity=$identity" >>"$GITHUB_OUTPUT"
|
||
echo "Classified $TAG as $identity"
|
||
|
||
# Require cargo before builder invokes the packaged Rust launcher hook.
|
||
- name: Ensure the Rust toolchain for the Windows CLI launcher
|
||
if: matrix.platform == 'win'
|
||
shell: pwsh
|
||
run: |
|
||
if (-not (Get-Command cargo -ErrorAction SilentlyContinue)) {
|
||
rustup toolchain install stable --profile minimal
|
||
rustup default stable
|
||
}
|
||
cargo --version
|
||
|
||
# Consumers verify the same official build configuration before restoring the bundle.
|
||
- name: Download release JavaScript
|
||
if: needs.release-javascript.outputs.supported == 'true'
|
||
uses: actions/download-artifact@v8
|
||
with:
|
||
name: release-javascript
|
||
path: .build/release-javascript
|
||
|
||
- name: Build app
|
||
shell: bash
|
||
run: |
|
||
if [ "$SHARED_JAVASCRIPT" = true ]; then
|
||
node config/scripts/release-javascript-artifact.mjs restore
|
||
pnpm run build:release:host
|
||
else
|
||
pnpm run build:release
|
||
fi
|
||
env:
|
||
# Why: Vite's web build crossed Node's default old-space ceiling on
|
||
# the macOS release runner, leaving v1.4.2-rc.8 as an incomplete draft.
|
||
NODE_OPTIONS: --max-old-space-size=4096
|
||
ORCA_BUILD_IDENTITY: ${{ steps.tag-classify.outputs.identity }}
|
||
SHARED_JAVASCRIPT: ${{ needs.release-javascript.outputs.supported }}
|
||
ORCA_RELEASE_JAVASCRIPT_SOURCE_SHA: ${{ needs.release-javascript.outputs.source_sha }}
|
||
ORCA_DIAGNOSTICS_TOKEN_URL: https://www.onorca.dev/diagnostics/token
|
||
ORCA_POSTHOG_WRITE_KEY: ${{ secrets.ORCA_POSTHOG_WRITE_KEY }}
|
||
|
||
# After the app build so nothing that cleans out/ can drop it; electron-builder ships it.
|
||
- name: Download the orcad deployment template
|
||
if: needs.cut.outputs.ships_orcad_template == 'true'
|
||
uses: actions/download-artifact@v8
|
||
with:
|
||
name: orcad-template
|
||
path: out/orcad-template
|
||
|
||
- name: Gate runtime file-watcher process isolation
|
||
if: runner.os == 'Linux'
|
||
run: |
|
||
# #8212: require Node and the exact Electron runtime to survive native SIGSEGV before packaging.
|
||
node config/scripts/runtime-file-watcher-fault-harness.mjs
|
||
ELECTRON_RUN_AS_NODE=1 pnpm exec electron config/scripts/runtime-file-watcher-fault-harness.mjs
|
||
|
||
# Publish hidden main sourcemaps separately because app.asar drops maps; one platform suffices for platform-independent main bundle.
|
||
- name: Bundle main-process source maps
|
||
id: bundle-main-sourcemaps
|
||
if: matrix.platform == 'linux-x64'
|
||
shell: bash
|
||
env:
|
||
TAG: ${{ needs.cut.outputs.tag }}
|
||
run: |
|
||
set -euo pipefail
|
||
if [ -z "$(find out/main -name '*.js.map' -print -quit)" ]; then
|
||
# Old valid cut tags predate hidden sourcemaps and have no bundle to publish.
|
||
if grep -Eq "sourcemap:[[:space:]]*['\"]hidden['\"]" electron.vite.config.ts; then
|
||
echo "::error::No main-process source maps in out/main despite build.sourcemap='hidden'."
|
||
exit 1
|
||
fi
|
||
echo "has_maps=false" >>"$GITHUB_OUTPUT"
|
||
echo "::notice::Cut ref predates hidden main-process source maps; skipping map publication."
|
||
exit 0
|
||
fi
|
||
echo "has_maps=true" >>"$GITHUB_OUTPUT"
|
||
# All-negation builder files prepend **/*; stage outside checkout to avoid packing maps into app.asar.
|
||
find out/main -name '*.js.map' -print | sort | zip -q -X "$RUNNER_TEMP/orca-sourcemaps-$TAG.zip" -@
|
||
ls -l "$RUNNER_TEMP/orca-sourcemaps-$TAG.zip"
|
||
|
||
- name: Publish main-process source maps
|
||
if: matrix.platform == 'linux-x64' && steps.bundle-main-sourcemaps.outputs.has_maps == 'true'
|
||
uses: nick-fields/retry@v4
|
||
with:
|
||
timeout_minutes: 10
|
||
max_attempts: 3
|
||
retry_wait_seconds: 30
|
||
command: gh release upload "${{ needs.cut.outputs.tag }}" "${{ runner.temp }}/orca-sourcemaps-${{ needs.cut.outputs.tag }}.zip" --clobber --repo "${{ github.repository }}"
|
||
env:
|
||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||
|
||
- name: Publish release artifacts (Linux)
|
||
if: matrix.platform == 'linux-x64' || matrix.platform == 'linux-arm64'
|
||
uses: nick-fields/retry@v4
|
||
with:
|
||
timeout_minutes: 30
|
||
max_attempts: 3
|
||
retry_wait_seconds: 30
|
||
command: ${{ matrix.release_command }}
|
||
env:
|
||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||
|
||
- name: Load packaged node-pty on the Linux floor
|
||
if: matrix.platform == 'linux-x64' || matrix.platform == 'linux-arm64'
|
||
uses: nick-fields/retry@v4
|
||
with:
|
||
timeout_minutes: 10
|
||
max_attempts: 3
|
||
retry_wait_seconds: 30
|
||
command: >-
|
||
node config/scripts/run-linux-packaged-node-pty-floor-smoke.mjs
|
||
--app-dir ${{ matrix.unpacked_dir }}
|
||
|
||
# Why: SignPath signs GitHub workflow artifacts, so Windows builds must
|
||
# upload only after the production-signed installer has been returned.
|
||
- name: Build Windows release artifacts
|
||
if: matrix.platform == 'win' && github.run_attempt == 1
|
||
uses: nick-fields/retry@v4
|
||
with:
|
||
timeout_minutes: 30
|
||
max_attempts: 3
|
||
retry_wait_seconds: 30
|
||
command: ${{ matrix.release_command }}
|
||
env:
|
||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||
# The release command prepares and verifies this host's Electron native modules first.
|
||
ORCA_REUSE_PREPARED_NATIVE_RUNTIME: '1'
|
||
# Capture NSIS uninstaller in sign hook before builder embeds/deletes it, including it in inner-binary signing. Keep capture under runner.temp: all-negation files includes checkout leftovers, and retry would pack unsigned uninstaller into app.asar.
|
||
ORCA_WIN_UNINSTALLER_EXPORT_PATH: ${{ runner.temp }}\uninstaller-signing\unsigned\orca-uninstaller.exe
|
||
|
||
- name: Verify Windows node-pty ConPTY runtime
|
||
if: matrix.platform == 'win' && github.run_attempt == 1
|
||
shell: pwsh
|
||
run: |
|
||
$runtimeDir = 'dist/win-unpacked/resources/node_modules/node-pty/build/Release'
|
||
$requiredFiles = @(
|
||
"$runtimeDir/conpty.node",
|
||
"$runtimeDir/conpty/conpty.dll",
|
||
"$runtimeDir/conpty/OpenConsole.exe"
|
||
)
|
||
foreach ($file in $requiredFiles) {
|
||
if (-not (Test-Path -LiteralPath $file -PathType Leaf)) {
|
||
throw "Missing Windows node-pty runtime file: $file"
|
||
}
|
||
Get-Item -LiteralPath $file
|
||
}
|
||
|
||
- name: Install SignPath PowerShell module
|
||
id: install-signpath
|
||
if: matrix.platform == 'win' && github.run_attempt == 1
|
||
uses: ./.github/actions/install-signpath-module
|
||
|
||
# #7785/#23383: sign inner PE zip, including captured NSIS uninstaller, then rebuild/sign installer; SignPath cannot deep-sign inside NSIS and this avoids a third approval.
|
||
|
||
# Stage only unsigned PE files; preserve existing vendor signatures such as Microsoft OpenConsole.
|
||
- name: Stage unsigned inner PE files for signing
|
||
id: stage-inner
|
||
if: matrix.platform == 'win' && github.run_attempt == 1
|
||
shell: pwsh
|
||
run: |
|
||
$root = Resolve-Path 'dist/win-unpacked'
|
||
$stage = New-Item -ItemType Directory -Force -Path 'signing-stage'
|
||
$list = New-Object System.Collections.Generic.List[string]
|
||
$skipped = New-Object System.Collections.Generic.List[string]
|
||
|
||
Get-ChildItem -Path $root -Recurse -File |
|
||
Where-Object { $_.Extension -in '.exe', '.dll', '.node' } |
|
||
ForEach-Object {
|
||
$relative = [System.IO.Path]::GetRelativePath($root, $_.FullName)
|
||
# The orcad template's Linux/macOS addons are data for SSH hosts, not PE files.
|
||
if ($relative -match '^resources[\\/]orcad-template[\\/]targets[\\/](?!win32-)') {
|
||
$skipped.Add("$relative <non-Windows orcad template payload>")
|
||
return
|
||
}
|
||
$signature = Get-AuthenticodeSignature -FilePath $_.FullName
|
||
if ($signature.Status -eq 'Valid') {
|
||
$skipped.Add("$relative <already signed: $($signature.SignerCertificate.Subject)>")
|
||
return
|
||
}
|
||
$destination = Join-Path $stage.FullName $relative
|
||
New-Item -ItemType Directory -Force -Path (Split-Path $destination) | Out-Null
|
||
Copy-Item -Path $_.FullName -Destination $destination -Force
|
||
$list.Add($relative)
|
||
}
|
||
|
||
if (-not ($list -contains 'Orca.exe')) {
|
||
throw 'Orca.exe was not staged for signing; unpacked layout changed?'
|
||
}
|
||
if (-not ($list | Where-Object { $_ -like '*conpty_console_list.node' })) {
|
||
throw 'node-pty conpty_console_list.node was not staged; this is the file from issue #7785.'
|
||
}
|
||
|
||
Set-Content -Path 'inner-signing-list.txt' -Value ($list -join "`n")
|
||
Write-Host "Staged $($list.Count) unsigned PE files for signing:"
|
||
$list | ForEach-Object { Write-Host " $_" }
|
||
Write-Host "Skipped $($skipped.Count) already-signed files:"
|
||
$skipped | ForEach-Object { Write-Host " $_" }
|
||
|
||
# Include MDE-flagged old-uninstaller.exe/Uninstall Orca.exe in this approval without another wait.
|
||
# Exclude it from win-unpacked’s copy-back list; the sign hook re-injects it during rebuild.
|
||
$exportedUninstaller = Join-Path $env:RUNNER_TEMP 'uninstaller-signing\unsigned\orca-uninstaller.exe'
|
||
if (-not (Test-Path -LiteralPath $exportedUninstaller)) {
|
||
throw "No exported NSIS uninstaller at $exportedUninstaller."
|
||
}
|
||
$uninstallerStagePath = Join-Path $stage.FullName 'uninstaller\orca-uninstaller.exe'
|
||
New-Item -ItemType Directory -Force -Path (Split-Path $uninstallerStagePath) -ErrorAction Stop | Out-Null
|
||
Copy-Item -LiteralPath $exportedUninstaller -Destination $uninstallerStagePath -Force -ErrorAction Stop
|
||
Write-Host 'Staged the NSIS uninstaller for signing: uninstaller\orca-uninstaller.exe'
|
||
|
||
- name: Upload unsigned inner binaries for SignPath
|
||
id: upload-unsigned-inner
|
||
if: matrix.platform == 'win' && github.run_attempt == 1 && steps.stage-inner.outcome == 'success'
|
||
uses: actions/upload-artifact@v7
|
||
with:
|
||
name: orca-windows-inner-unsigned-${{ needs.cut.outputs.tag }}
|
||
path: signing-stage/**
|
||
if-no-files-found: error
|
||
|
||
# Unsigned packaging overlaps macOS; only successful macOS builds may consume signing quota.
|
||
- name: Require macOS success before Windows signing
|
||
id: mac-signing-gate
|
||
if: matrix.platform == 'win' && github.run_attempt == 1
|
||
shell: bash
|
||
run: node config/scripts/run-release-mac-build-workflow.mjs wait
|
||
env:
|
||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||
RELEASE_MAC_BUILD_REF: ${{ github.ref_name }}
|
||
RELEASE_MAC_BUILD_RELEASE_RUN_ID: ${{ github.run_id }}
|
||
RELEASE_MAC_BUILD_RUN_ID: ${{ needs.build-mac.outputs.run_id }}
|
||
RELEASE_MAC_BUILD_WORKFLOW_SHA: ${{ needs.build-mac.outputs.workflow_sha }}
|
||
RELEASE_MAC_BUILD_RUN_ATTEMPT: ${{ needs.build-mac.outputs.run_attempt }}
|
||
RELEASE_MAC_BUILD_WAIT_DEADLINE_MS: ${{ needs.build-mac.outputs.wait_deadline_ms }}
|
||
RELEASE_MAC_BUILD_TAG: ${{ needs.cut.outputs.tag }}
|
||
RELEASE_MAC_BUILD_WORKFLOW: release-mac-build.yml
|
||
|
||
- name: Submit inner binaries signing request
|
||
id: submit-inner-signing
|
||
if: matrix.platform == 'win' && github.run_attempt == 1 && steps.mac-signing-gate.outcome == 'success' && steps.upload-unsigned-inner.outcome == 'success'
|
||
uses: signpath/github-action-submit-signing-request@v2
|
||
with:
|
||
api-token: ${{ secrets.SIGNPATH_API_TOKEN }}
|
||
organization-id: c37aa192-a27a-4377-9c90-5d6c95912dc0
|
||
project-slug: orca
|
||
signing-policy-slug: release-signing
|
||
artifact-configuration-slug: windows-inner-binaries-zip
|
||
github-artifact-id: ${{ steps.upload-unsigned-inner.outputs.artifact-id }}
|
||
wait-for-completion: false
|
||
|
||
- name: Notify Slack that inner-binary signing is waiting for approval
|
||
id: notify-inner-signing
|
||
continue-on-error: true
|
||
if: matrix.platform == 'win' && github.run_attempt == 1 && steps.submit-inner-signing.outcome == 'success'
|
||
shell: pwsh
|
||
env:
|
||
SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}
|
||
SIGNPATH_ORGANIZATION_ID: c37aa192-a27a-4377-9c90-5d6c95912dc0
|
||
SIGNPATH_REQUEST_ID: ${{ steps.submit-inner-signing.outputs.signing-request-id }}
|
||
SIGNPATH_REQUEST_URL: ${{ steps.submit-inner-signing.outputs.signing-request-web-url }}
|
||
TAG: ${{ needs.cut.outputs.tag }}
|
||
SOURCE_REF: ${{ needs.cut.outputs.source_ref }}
|
||
SOURCE_SHA: ${{ needs.cut.outputs.source_sha }}
|
||
SOURCE_SHORT_SHA: ${{ needs.cut.outputs.source_short_sha }}
|
||
# Prefer triggering_actor so re-runs name who re-ran; fall back to actor.
|
||
CUT_BY: ${{ github.triggering_actor || github.actor }}
|
||
REPO_URL: ${{ github.server_url }}/${{ github.repository }}
|
||
GITHUB_RUN_URL: https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}
|
||
run: |
|
||
if ([string]::IsNullOrWhiteSpace($env:SLACK_WEBHOOK_URL)) {
|
||
throw 'SLACK_WEBHOOK_URL secret is required so release approvers know when SignPath is waiting.'
|
||
}
|
||
|
||
$requestUrl = $env:SIGNPATH_REQUEST_URL
|
||
if ([string]::IsNullOrWhiteSpace($requestUrl)) {
|
||
$requestUrl = "https://app.signpath.io/Web/$env:SIGNPATH_ORGANIZATION_ID/SigningRequests/$env:SIGNPATH_REQUEST_ID"
|
||
}
|
||
|
||
# Why: approvers need tag + source ref/commit + who cut, not only the tag.
|
||
$sourceRef = if (-not [string]::IsNullOrWhiteSpace($env:SOURCE_REF)) { $env:SOURCE_REF } else { 'unknown' }
|
||
$shortSha = if (-not [string]::IsNullOrWhiteSpace($env:SOURCE_SHORT_SHA)) {
|
||
$env:SOURCE_SHORT_SHA
|
||
} elseif (-not [string]::IsNullOrWhiteSpace($env:SOURCE_SHA)) {
|
||
$env:SOURCE_SHA.Substring(0, [Math]::Min(12, $env:SOURCE_SHA.Length))
|
||
} else {
|
||
'unknown'
|
||
}
|
||
$commitLink = if (-not [string]::IsNullOrWhiteSpace($env:SOURCE_SHA)) {
|
||
"<$($env:REPO_URL)/commit/$($env:SOURCE_SHA)|``$shortSha``>"
|
||
} else {
|
||
"``$shortSha``"
|
||
}
|
||
$cutBy = if (-not [string]::IsNullOrWhiteSpace($env:CUT_BY)) {
|
||
"<https://github.com/$($env:CUT_BY)|@$($env:CUT_BY)>"
|
||
} else {
|
||
'unknown'
|
||
}
|
||
$message = "Orca Windows release ``$($env:TAG)`` inner-binaries signing request (1 of 2) is ready for SignPath approval.`nSource: ``$sourceRef`` @ $commitLink · cut by $cutBy`n<$requestUrl|Open SignPath signing request>`n<$($env:GITHUB_RUN_URL)|Open GitHub Actions run>"
|
||
$payload = @{
|
||
text = $message
|
||
blocks = @(
|
||
@{
|
||
type = 'section'
|
||
text = @{
|
||
type = 'mrkdwn'
|
||
text = $message
|
||
}
|
||
}
|
||
)
|
||
} | ConvertTo-Json -Depth 5
|
||
|
||
Invoke-RestMethod -Method Post -Uri $env:SLACK_WEBHOOK_URL -ContentType 'application/json' -Body $payload
|
||
|
||
# Approval remains required even when the notification service is unavailable.
|
||
- name: Download signed inner binaries from SignPath
|
||
id: download-signed-inner
|
||
if: matrix.platform == 'win' && github.run_attempt == 1 && steps.submit-inner-signing.outcome == 'success'
|
||
shell: pwsh
|
||
env:
|
||
SIGNPATH_API_TOKEN: ${{ secrets.SIGNPATH_API_TOKEN }}
|
||
SIGNPATH_REQUEST_ID: ${{ steps.submit-inner-signing.outputs.signing-request-id }}
|
||
run: |
|
||
Get-SignedArtifact `
|
||
-OrganizationId c37aa192-a27a-4377-9c90-5d6c95912dc0 `
|
||
-ApiToken $env:SIGNPATH_API_TOKEN `
|
||
-SigningRequestId $env:SIGNPATH_REQUEST_ID `
|
||
-OutputArtifactPath signed-inner.zip `
|
||
-Force `
|
||
-WaitForCompletionTimeoutInSeconds 3600
|
||
|
||
New-Item -ItemType Directory -Path signed-inner -Force
|
||
Expand-Archive -Path signed-inner.zip -DestinationPath signed-inner -Force
|
||
|
||
# Copy back only staged paths; layout mismatch must fail before mixed signed/unsigned assets ship.
|
||
- name: Restore signed inner binaries into unpacked app
|
||
id: restore-signed-inner
|
||
if: matrix.platform == 'win' && github.run_attempt == 1 && steps.download-signed-inner.outcome == 'success'
|
||
shell: pwsh
|
||
env:
|
||
SIGNING_POLICY: release-signing
|
||
run: |
|
||
$requireValid = $env:SIGNING_POLICY -ne 'test-signing'
|
||
$root = Resolve-Path 'dist/win-unpacked'
|
||
$failures = New-Object System.Collections.Generic.List[string]
|
||
foreach ($relative in Get-Content 'inner-signing-list.txt') {
|
||
$candidates = @(
|
||
(Join-Path 'signed-inner' $relative),
|
||
(Join-Path 'signed-inner/signing-stage' $relative)
|
||
) | Where-Object { Test-Path -LiteralPath $_ -PathType Leaf }
|
||
if (@($candidates).Count -ne 1) {
|
||
$failures.Add("missing or ambiguous signed artifact path: $relative")
|
||
continue
|
||
}
|
||
$signed = Get-Item -LiteralPath @($candidates)[0]
|
||
$signature = Get-AuthenticodeSignature -FilePath $signed.FullName
|
||
if ($null -eq $signature.SignerCertificate -or ($requireValid -and ($signature.Status -ne 'Valid' -or $signature.SignerCertificate.Subject -notlike '*CN=SignPath Foundation*'))) {
|
||
$failures.Add("returned without a valid SignPath signature: $relative")
|
||
continue
|
||
}
|
||
Copy-Item -Path $signed.FullName -Destination (Join-Path $root $relative) -Force
|
||
Write-Host ("{0,-14} {1} <{2}>" -f $signature.Status, $relative, $signature.SignerCertificate.Subject)
|
||
}
|
||
if ($failures.Count -gt 0) {
|
||
$failures | ForEach-Object { Write-Host "::error::$_" }
|
||
throw "Signed inner artifact did not round-trip cleanly ($($failures.Count) failures)."
|
||
}
|
||
|
||
# Recompute template checksums after SignPath rewrites Windows binaries so materialization validates signed bytes.
|
||
- name: Reseal the orcad template over its signed binaries
|
||
id: reseal-orcad-template
|
||
if: matrix.platform == 'win' && github.run_attempt == 1 && steps.restore-signed-inner.outcome == 'success' && needs.cut.outputs.ships_orcad_template == 'true'
|
||
run: node config/scripts/packaged-orcad-template.cjs --reseal-signed dist/win-unpacked inner-signing-list.txt
|
||
|
||
# The uninstaller must return signed before rebuilding the installer.
|
||
- name: Restore signed uninstaller for the installer rebuild
|
||
id: restore-signed-uninstaller
|
||
if: matrix.platform == 'win' && github.run_attempt == 1 && steps.restore-signed-inner.outcome == 'success'
|
||
shell: pwsh
|
||
env:
|
||
SIGNING_POLICY: release-signing
|
||
run: |
|
||
$requireValid = $env:SIGNING_POLICY -ne 'test-signing'
|
||
$candidates = @(
|
||
(Join-Path 'signed-inner' 'uninstaller\orca-uninstaller.exe'),
|
||
(Join-Path 'signed-inner/signing-stage' 'uninstaller\orca-uninstaller.exe')
|
||
) | Where-Object { Test-Path -LiteralPath $_ -PathType Leaf }
|
||
if (@($candidates).Count -ne 1) {
|
||
throw 'Missing or ambiguous uninstaller/orca-uninstaller.exe in the signed artifact; check the windows-inner-binaries-zip configuration.'
|
||
}
|
||
$signed = Get-Item -LiteralPath @($candidates)[0]
|
||
$signature = Get-AuthenticodeSignature -FilePath $signed.FullName
|
||
if ($null -eq $signature.SignerCertificate -or ($requireValid -and ($signature.Status -ne 'Valid' -or $signature.SignerCertificate.Subject -notlike '*CN=SignPath Foundation*'))) {
|
||
throw 'The returned NSIS uninstaller carries no valid SignPath signature.'
|
||
}
|
||
$signedDir = Join-Path $env:RUNNER_TEMP 'uninstaller-signing\signed'
|
||
New-Item -ItemType Directory -Force -Path $signedDir | Out-Null
|
||
Copy-Item -LiteralPath $signed.FullName -Destination (Join-Path $signedDir 'orca-uninstaller.exe') -Force
|
||
Write-Host ("{0,-14} uninstaller <{1}>" -f $signature.Status, $signature.SignerCertificate.Subject)
|
||
|
||
# Builder recopies cached elevate.exe on every NSIS pack, clobbering signature in v1.4.129-rc.4; replace cache copy with signed bytes before rebuild. Persisted timestamped signatures may skip later staging; shipped-installer evidence still checks them. Scripted lookup fails missing cache paths: old nonexistent <cache>/nsis plus silent success shipped unsigned elevate.exe in v1.4.193/v1.4.194.
|
||
- name: Replace cached elevate.exe with the signed copy
|
||
id: sign-elevate-cache
|
||
if: matrix.platform == 'win' && github.run_attempt == 1 && steps.restore-signed-inner.outcome == 'success'
|
||
shell: pwsh
|
||
run: |
|
||
$signed = 'dist/win-unpacked/resources/elevate.exe'
|
||
if (-not (Test-Path $signed)) {
|
||
throw 'No elevate.exe in win-unpacked resources.'
|
||
}
|
||
# Rehearsal shares the builder cache: never seed a release with its test-certificate elevate.exe.
|
||
$signature = Get-AuthenticodeSignature -FilePath $signed
|
||
$subject = if ($null -eq $signature.SignerCertificate) { '<none>' } else { $signature.SignerCertificate.Subject }
|
||
if ($signature.Status -ne 'Valid' -or $subject -notlike '*CN=SignPath Foundation*') {
|
||
throw "win-unpacked elevate.exe is not SignPath-signed ($($signature.Status), $subject)."
|
||
}
|
||
node config/scripts/replace-cached-nsis-elevate.mjs $signed
|
||
if ($LASTEXITCODE -ne 0) {
|
||
$message = 'Cached elevate.exe swap found nothing to replace; the rebuilt installer ships an unsigned UAC elevation helper (issue #7785).'
|
||
if ($env:GITHUB_STEP_SUMMARY) {
|
||
try {
|
||
Add-Content -Path $env:GITHUB_STEP_SUMMARY -Value "**Windows elevate.exe cache swap:** FAILED — $message" -ErrorAction Stop
|
||
} catch {
|
||
Write-Host "::warning::Could not write the elevate.exe swap verdict to the job summary: $_"
|
||
}
|
||
}
|
||
throw $message
|
||
}
|
||
|
||
- name: Rebuild NSIS installer from signed unpacked app
|
||
id: rebuild-nsis-signed
|
||
if: matrix.platform == 'win' && github.run_attempt == 1 && steps.restore-signed-inner.outcome == 'success'
|
||
shell: pwsh
|
||
env:
|
||
ORCA_WIN_UNINSTALLER_SIGNED_PATH: ${{ runner.temp }}\uninstaller-signing\signed\orca-uninstaller.exe
|
||
run: |
|
||
pnpm exec electron-builder --config config/electron-builder.config.cjs --win --publish never --prepackaged "$env:GITHUB_WORKSPACE\dist\win-unpacked"
|
||
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
|
||
if (-not (Test-Path 'dist/orca-windows-setup.exe')) {
|
||
throw 'electron-builder --prepackaged did not produce dist/orca-windows-setup.exe'
|
||
}
|
||
|
||
# ── End Windows inner-binary signing ───────────────────────────────
|
||
- name: Upload unsigned Windows installer for SignPath
|
||
if: matrix.platform == 'win' && github.run_attempt == 1
|
||
id: upload-unsigned-windows-installer
|
||
uses: actions/upload-artifact@v7
|
||
with:
|
||
name: orca-windows-unsigned-${{ needs.cut.outputs.tag }}
|
||
path: dist/orca-windows-setup.exe
|
||
compression-level: 0
|
||
if-no-files-found: error
|
||
|
||
# Why: SignPath Foundation production certificates require manual review,
|
||
# so the release job waits while the signing request is approved in UI.
|
||
- name: Submit Windows installer signing request
|
||
id: submit-signing-request
|
||
if: matrix.platform == 'win' && github.run_attempt == 1 && steps.mac-signing-gate.outcome == 'success' && steps.upload-unsigned-windows-installer.outcome == 'success'
|
||
uses: signpath/github-action-submit-signing-request@v2
|
||
with:
|
||
api-token: ${{ secrets.SIGNPATH_API_TOKEN }}
|
||
organization-id: c37aa192-a27a-4377-9c90-5d6c95912dc0
|
||
project-slug: orca
|
||
signing-policy-slug: release-signing
|
||
artifact-configuration-slug: github-actions-windows-installer
|
||
github-artifact-id: ${{ steps.upload-unsigned-windows-installer.outputs.artifact-id }}
|
||
wait-for-completion: false
|
||
|
||
- name: Notify Slack that Windows signing is waiting for approval
|
||
if: matrix.platform == 'win' && github.run_attempt == 1
|
||
shell: pwsh
|
||
env:
|
||
SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}
|
||
SIGNPATH_ORGANIZATION_ID: c37aa192-a27a-4377-9c90-5d6c95912dc0
|
||
SIGNPATH_REQUEST_ID: ${{ steps.submit-signing-request.outputs.signing-request-id }}
|
||
SIGNPATH_REQUEST_URL: ${{ steps.submit-signing-request.outputs.signing-request-web-url }}
|
||
TAG: ${{ needs.cut.outputs.tag }}
|
||
SOURCE_REF: ${{ needs.cut.outputs.source_ref }}
|
||
SOURCE_SHA: ${{ needs.cut.outputs.source_sha }}
|
||
SOURCE_SHORT_SHA: ${{ needs.cut.outputs.source_short_sha }}
|
||
# Prefer triggering_actor so re-runs name who re-ran; fall back to actor.
|
||
CUT_BY: ${{ github.triggering_actor || github.actor }}
|
||
REPO_URL: ${{ github.server_url }}/${{ github.repository }}
|
||
GITHUB_RUN_URL: https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}
|
||
INNER_SIGNING_SUBMITTED: ${{ steps.submit-inner-signing.outcome == 'success' }}
|
||
run: |
|
||
if ([string]::IsNullOrWhiteSpace($env:SLACK_WEBHOOK_URL)) {
|
||
throw 'SLACK_WEBHOOK_URL secret is required so release approvers know when SignPath is waiting.'
|
||
}
|
||
|
||
$requestUrl = $env:SIGNPATH_REQUEST_URL
|
||
if ([string]::IsNullOrWhiteSpace($requestUrl)) {
|
||
$requestUrl = "https://app.signpath.io/Web/$env:SIGNPATH_ORGANIZATION_ID/SigningRequests/$env:SIGNPATH_REQUEST_ID"
|
||
}
|
||
|
||
# Keep the two approval requests distinguishable in the notification.
|
||
$stage = if ($env:INNER_SIGNING_SUBMITTED -eq 'true') { 'installer signing request (2 of 2)' } else { 'signing request' }
|
||
# Why: approvers need tag + source ref/commit + who cut, not only the tag.
|
||
$sourceRef = if (-not [string]::IsNullOrWhiteSpace($env:SOURCE_REF)) { $env:SOURCE_REF } else { 'unknown' }
|
||
$shortSha = if (-not [string]::IsNullOrWhiteSpace($env:SOURCE_SHORT_SHA)) {
|
||
$env:SOURCE_SHORT_SHA
|
||
} elseif (-not [string]::IsNullOrWhiteSpace($env:SOURCE_SHA)) {
|
||
$env:SOURCE_SHA.Substring(0, [Math]::Min(12, $env:SOURCE_SHA.Length))
|
||
} else {
|
||
'unknown'
|
||
}
|
||
$commitLink = if (-not [string]::IsNullOrWhiteSpace($env:SOURCE_SHA)) {
|
||
"<$($env:REPO_URL)/commit/$($env:SOURCE_SHA)|``$shortSha``>"
|
||
} else {
|
||
"``$shortSha``"
|
||
}
|
||
$cutBy = if (-not [string]::IsNullOrWhiteSpace($env:CUT_BY)) {
|
||
"<https://github.com/$($env:CUT_BY)|@$($env:CUT_BY)>"
|
||
} else {
|
||
'unknown'
|
||
}
|
||
$message = "Orca Windows release ``$($env:TAG)`` $stage is ready for SignPath approval.`nSource: ``$sourceRef`` @ $commitLink · cut by $cutBy`n<$requestUrl|Open SignPath signing request>`n<$($env:GITHUB_RUN_URL)|Open GitHub Actions run>"
|
||
$payload = @{
|
||
text = $message
|
||
blocks = @(
|
||
@{
|
||
type = 'section'
|
||
text = @{
|
||
type = 'mrkdwn'
|
||
text = $message
|
||
}
|
||
}
|
||
)
|
||
} | ConvertTo-Json -Depth 5
|
||
|
||
Invoke-RestMethod -Method Post -Uri $env:SLACK_WEBHOOK_URL -ContentType 'application/json' -Body $payload
|
||
|
||
- name: Download signed Windows installer from SignPath
|
||
if: matrix.platform == 'win' && github.run_attempt == 1
|
||
shell: pwsh
|
||
env:
|
||
SIGNPATH_API_TOKEN: ${{ secrets.SIGNPATH_API_TOKEN }}
|
||
SIGNPATH_REQUEST_ID: ${{ steps.submit-signing-request.outputs.signing-request-id }}
|
||
run: |
|
||
Get-SignedArtifact `
|
||
-OrganizationId c37aa192-a27a-4377-9c90-5d6c95912dc0 `
|
||
-ApiToken $env:SIGNPATH_API_TOKEN `
|
||
-SigningRequestId $env:SIGNPATH_REQUEST_ID `
|
||
-OutputArtifactPath signed-windows.zip `
|
||
-Force `
|
||
-WaitForCompletionTimeoutInSeconds 14400
|
||
|
||
New-Item -ItemType Directory -Path signed-windows -Force
|
||
Expand-Archive -Path signed-windows.zip -DestinationPath signed-windows -Force
|
||
|
||
- name: Stage signed Windows release assets
|
||
if: matrix.platform == 'win' && github.run_attempt == 1
|
||
shell: pwsh
|
||
run: |
|
||
$signedInstaller = Get-ChildItem -Path signed-windows -Recurse -File -Filter 'orca-windows-setup.exe' | Select-Object -First 1
|
||
if ($null -eq $signedInstaller) {
|
||
throw 'Signed Windows installer was not returned by SignPath.'
|
||
}
|
||
|
||
Copy-Item -Path $signedInstaller.FullName -Destination 'dist/orca-windows-setup.exe' -Force
|
||
node config/scripts/generate-windows-blockmap.mjs 'dist/orca-windows-setup.exe' 'dist/orca-windows-setup.exe.blockmap'
|
||
if ($LASTEXITCODE -ne 0) { throw "blockmap generation failed with exit code $LASTEXITCODE" }
|
||
|
||
$installer = Get-Item 'dist/orca-windows-setup.exe'
|
||
$blockmap = Get-Item 'dist/orca-windows-setup.exe.blockmap'
|
||
$stream = [System.IO.File]::OpenRead($installer.FullName)
|
||
try {
|
||
$sha512 = [System.Security.Cryptography.SHA512]::Create()
|
||
$hash = [Convert]::ToBase64String($sha512.ComputeHash($stream))
|
||
} finally {
|
||
if ($null -ne $sha512) {
|
||
$sha512.Dispose()
|
||
}
|
||
$stream.Dispose()
|
||
}
|
||
|
||
$latestYml = Get-Content -Path 'dist/latest.yml' -Raw
|
||
$latestYml = [regex]::Replace($latestYml, '(?m)^(\s*)sha512: .+$', {
|
||
param($match)
|
||
"$($match.Groups[1].Value)sha512: $hash"
|
||
})
|
||
$latestYml = $latestYml -replace '(?m)^ size: \d+$', " size: $($installer.Length)"
|
||
$latestYml = $latestYml -replace '(?m)^ blockMapSize: \d+$', " blockMapSize: $($blockmap.Length)"
|
||
Set-Content -Path 'dist/latest.yml' -Value $latestYml -NoNewline
|
||
|
||
Get-Item 'dist/orca-windows-setup.exe', 'dist/orca-windows-setup.exe.blockmap', 'dist/latest.yml'
|
||
|
||
- name: Verify signed Windows installer
|
||
if: matrix.platform == 'win' && github.run_attempt == 1
|
||
shell: pwsh
|
||
run: |
|
||
$signature = Get-AuthenticodeSignature -FilePath 'dist/orca-windows-setup.exe'
|
||
if ($signature.Status -ne 'Valid') {
|
||
throw ($signature | Format-List * | Out-String)
|
||
}
|
||
if ($signature.SignerCertificate.Subject -notlike '*CN=SignPath Foundation*') {
|
||
throw "Unexpected Windows signer: $($signature.SignerCertificate.Subject)"
|
||
}
|
||
$signature.SignerCertificate | Format-List Subject,Issuer,NotBefore,NotAfter,Thumbprint
|
||
|
||
# #7785 evidence extracts every staged exe/dll/node from shipped installer, superseding #7170’s Orca.exe-only gate; unsigned assets block publish.
|
||
- name: Verify Windows inner binary signatures
|
||
if: matrix.platform == 'win' && github.run_attempt == 1
|
||
shell: pwsh
|
||
env:
|
||
ORCA_WINDOWS_INNER_SIGNATURE_REQUIRED: 'true'
|
||
INNER_SIGNING_COMPLETED: ${{ steps.rebuild-nsis-signed.outcome == 'success' }}
|
||
UNINSTALLER_SIGNING_COMPLETED: ${{ steps.restore-signed-uninstaller.outcome == 'success' }}
|
||
run: |
|
||
$required = $env:ORCA_WINDOWS_INNER_SIGNATURE_REQUIRED -eq 'true'
|
||
|
||
# Keep diagnostics best-effort so they cannot mask a signature failure.
|
||
function Add-GateEvidence([string]$line) {
|
||
try {
|
||
Add-Content -Path 'inner-signing-evidence.txt' -Value "`n$line" -ErrorAction Stop
|
||
} catch {
|
||
Write-Host "::warning::Could not append to the inner-signing evidence file: $_"
|
||
}
|
||
}
|
||
|
||
function Add-GateSummary([string]$verdict) {
|
||
if (-not $env:GITHUB_STEP_SUMMARY) { return }
|
||
try {
|
||
Add-Content -Path $env:GITHUB_STEP_SUMMARY -Value "**Windows inner-binary signing:** $verdict" -ErrorAction Stop
|
||
} catch {
|
||
Write-Host "::warning::Could not write inner-signing verdict to the job summary: $_"
|
||
}
|
||
}
|
||
|
||
function Write-GateVerdict([string]$verdict) {
|
||
try {
|
||
Set-Content -Path 'inner-signing-evidence.txt' -Value $verdict -ErrorAction Stop
|
||
} catch {
|
||
Write-Host "::warning::Could not persist inner-signing verdict: $_"
|
||
}
|
||
Add-GateSummary $verdict
|
||
}
|
||
|
||
if ($env:INNER_SIGNING_COMPLETED -ne 'true') {
|
||
$message = 'Windows inner-binary signing did not complete; publication is blocked.'
|
||
Write-GateVerdict "NOT VERIFIED — $message"
|
||
if ($required) { throw $message }
|
||
Write-Host "::warning::$message"
|
||
exit 0
|
||
}
|
||
|
||
# Keep signature failures outside the catch to preserve the per-file report.
|
||
$policyFailure = $null
|
||
|
||
try {
|
||
$report = New-Object System.Collections.Generic.List[string]
|
||
$failures = New-Object System.Collections.Generic.List[string]
|
||
|
||
# Verify shipped NSIS payload bytes via 7z, not the build tree. Resolve 7za through app-builder-lib:
|
||
# builder 26.9+ dropped bundled 7zip-bin (#6487).
|
||
$7zaOutput = node config/scripts/resolve-7za-path.mjs
|
||
$7zaExitCode = $LASTEXITCODE
|
||
if ($7zaExitCode -ne 0) {
|
||
throw "The 7za resolver exited with code $7zaExitCode for the inner-binary evidence gate."
|
||
}
|
||
$7za = ($7zaOutput | Out-String).Trim()
|
||
if ([string]::IsNullOrWhiteSpace($7za) -or -not (Test-Path -LiteralPath $7za -PathType Leaf)) {
|
||
throw "The 7za resolver returned an invalid path for the inner-binary evidence gate: $7za"
|
||
}
|
||
New-Item -ItemType Directory -Path inner-evidence-extract -Force | Out-Null
|
||
& $7za x 'dist/orca-windows-setup.exe' '-oinner-evidence-extract' -y | Out-Null
|
||
if ($LASTEXITCODE -ne 0) { throw 'Could not extract the shipped installer payload.' }
|
||
|
||
$root = Resolve-Path 'inner-evidence-extract'
|
||
# Verify the CLI even when a cached signed copy bypassed staging (#23383).
|
||
# Always verify elevate.exe, even when cached signatures skipped staging; NSIS rebuild can clobber it.
|
||
$targets = @(Get-Content 'inner-signing-list.txt')
|
||
foreach ($requiredTarget in @('Orca.exe', 'resources\bin\orca.exe', 'resources\bin\orca-agent-hook.exe')) {
|
||
if ($targets -notcontains $requiredTarget) { $targets += $requiredTarget }
|
||
}
|
||
if ($targets -notcontains 'resources\elevate.exe') {
|
||
$targets += 'resources\elevate.exe'
|
||
}
|
||
# The uninstaller lives in NSIS’s compressed data section, outside the app 7z payload.
|
||
# A receipt proves the sign-hook import ran, not the installed bytes; its digest equality is by construction.
|
||
# Check that input’s signature here; windows-signing-rehearsal installs/inspects the actual shipped uninstaller.
|
||
if ($env:UNINSTALLER_SIGNING_COMPLETED -eq 'true') {
|
||
$signedUninstaller = Join-Path $env:RUNNER_TEMP 'uninstaller-signing\signed\orca-uninstaller.exe'
|
||
$receipt = "$signedUninstaller.embedded-sha256"
|
||
if (-not (Test-Path -LiteralPath $receipt)) {
|
||
$failures.Add('the sign hook did not embed the signed uninstaller into the rebuilt installer')
|
||
} else {
|
||
$embedded = (Get-Content -LiteralPath $receipt -Raw).Trim()
|
||
$actual = (Get-FileHash -LiteralPath $signedUninstaller -Algorithm SHA256).Hash.ToLowerInvariant()
|
||
$signature = Get-AuthenticodeSignature -FilePath $signedUninstaller
|
||
$subject = if ($null -eq $signature.SignerCertificate) { '<none>' } else { $signature.SignerCertificate.Subject }
|
||
$line = "{0,-14} {1} <{2}>" -f $signature.Status, 'Uninstall Orca.exe (embedded)', $subject
|
||
$report.Add($line)
|
||
Write-Host $line
|
||
if ($embedded -ne $actual) {
|
||
$failures.Add("the rebuilt installer embedded different uninstaller bytes than the signed one ($embedded vs $actual)")
|
||
} elseif ($signature.Status -ne 'Valid' -or $subject -notlike '*CN=SignPath Foundation*') {
|
||
$failures.Add("not signed by SignPath Foundation: Uninstall Orca.exe ($($signature.Status), $subject)")
|
||
}
|
||
}
|
||
} else {
|
||
$failures.Add('The NSIS uninstaller signing did not complete.')
|
||
}
|
||
foreach ($relative in $targets) {
|
||
$path = Join-Path $root $relative
|
||
if (-not (Test-Path $path)) {
|
||
$failures.Add("missing from installer payload: $relative")
|
||
continue
|
||
}
|
||
$signature = Get-AuthenticodeSignature -FilePath $path
|
||
$subject = if ($null -eq $signature.SignerCertificate) { '<none>' } else { $signature.SignerCertificate.Subject }
|
||
$line = "{0,-14} {1} <{2}>" -f $signature.Status, $relative, $subject
|
||
$report.Add($line)
|
||
Write-Host $line
|
||
if ($signature.Status -ne 'Valid' -or $subject -notlike '*CN=SignPath Foundation*') {
|
||
$failures.Add("not signed by SignPath Foundation: $relative ($($signature.Status), $subject)")
|
||
}
|
||
}
|
||
|
||
Set-Content -Path 'inner-signing-evidence.txt' -Value ($report -join "`n")
|
||
if ($failures.Count -gt 0) {
|
||
$failures | ForEach-Object { Write-Host "::warning::$_" }
|
||
$message = "Windows inner-binary evidence gate found $($failures.Count) problems."
|
||
# Set policyFailure before I/O: write errors must not turn FAILED into ERRORED and overwrite per-file evidence.
|
||
if ($required) {
|
||
$policyFailure = $message
|
||
} else {
|
||
Write-Host "::warning::$message Fail-open until ORCA_WINDOWS_INNER_SIGNATURE_REQUIRED is 'true'."
|
||
}
|
||
Add-GateEvidence "VERDICT: FAILED — $message"
|
||
Add-GateSummary "FAILED — $message"
|
||
} else {
|
||
# Use report, not extracted targets: it also includes the embedded uninstaller.
|
||
$ok = "All $($report.Count) checked binaries are signed by SignPath Foundation."
|
||
Add-GateEvidence "VERDICT: PASSED — $ok"
|
||
Add-GateSummary "PASSED — $ok"
|
||
Write-Host $ok
|
||
}
|
||
} catch {
|
||
Write-GateVerdict "ERRORED — $_"
|
||
if ($required) { throw }
|
||
Write-Host "::warning::Windows inner-binary evidence gate errored: $_ (fail-open, issue #7785)."
|
||
}
|
||
|
||
# Outside the catch so the FAILED evidence report survives intact.
|
||
if ($policyFailure) { throw $policyFailure }
|
||
|
||
- name: Notify Slack when Windows signing fails
|
||
if: failure() && matrix.platform == 'win' && github.run_attempt == 1 && steps.install-signpath.outcome != '' && steps.install-signpath.outcome != 'skipped'
|
||
continue-on-error: true
|
||
shell: pwsh
|
||
env:
|
||
SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}
|
||
TAG: ${{ needs.cut.outputs.tag }}
|
||
GITHUB_RUN_URL: https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}
|
||
INNER_REQUEST_ID: ${{ steps.submit-inner-signing.outputs.signing-request-id }}
|
||
INSTALLER_REQUEST_ID: ${{ steps.submit-signing-request.outputs.signing-request-id }}
|
||
INSTALLER_SUBMISSION_OUTCOME: ${{ steps.submit-signing-request.outcome }}
|
||
run: |
|
||
if ([string]::IsNullOrWhiteSpace($env:SLACK_WEBHOOK_URL)) {
|
||
throw 'SLACK_WEBHOOK_URL is missing; cannot notify release approvers of the signing failure.'
|
||
}
|
||
$stage = 'inner-binary signing or installer rebuild'
|
||
if ($env:INSTALLER_SUBMISSION_OUTCOME -in @('success', 'failure')) {
|
||
$stage = 'installer signing or final signature verification'
|
||
}
|
||
$message = "Orca Windows release ``$($env:TAG)`` failed during $stage (including approval timeouts). Publication is blocked.`n<$($env:GITHUB_RUN_URL)|Open failed GitHub Actions run>"
|
||
foreach ($request in @(
|
||
@{ Id = $env:INNER_REQUEST_ID; Label = 'Inner-binary signing request' },
|
||
@{ Id = $env:INSTALLER_REQUEST_ID; Label = 'Installer signing request' }
|
||
)) {
|
||
if (-not [string]::IsNullOrWhiteSpace($request.Id)) {
|
||
$message += "`n<https://app.signpath.io/Web/c37aa192-a27a-4377-9c90-5d6c95912dc0/SigningRequests/$($request.Id)|$($request.Label)>"
|
||
}
|
||
}
|
||
$message += "`nCheck the failed step before retrying. Late SignPath approval does not resume this run; missing Windows assets require a fresh release dispatch, not Re-run failed jobs."
|
||
$payload = @{ text = $message } | ConvertTo-Json
|
||
Invoke-RestMethod -Method Post -Uri $env:SLACK_WEBHOOK_URL -ContentType 'application/json' -Body $payload
|
||
|
||
- name: Upload Windows inner signing evidence
|
||
if: always() && matrix.platform == 'win' && github.run_attempt == 1
|
||
uses: actions/upload-artifact@v7
|
||
with:
|
||
name: orca-windows-inner-signing-evidence-${{ needs.cut.outputs.tag }}
|
||
path: |
|
||
inner-signing-evidence.txt
|
||
inner-signing-list.txt
|
||
if-no-files-found: ignore
|
||
retention-days: 30
|
||
|
||
- name: Publish signed Windows release artifacts
|
||
if: matrix.platform == 'win' && github.run_attempt == 1
|
||
uses: nick-fields/retry@v4
|
||
with:
|
||
timeout_minutes: 10
|
||
max_attempts: 3
|
||
retry_wait_seconds: 30
|
||
command: gh release upload "${{ needs.cut.outputs.tag }}" "dist/orca-windows-setup.exe" "dist/orca-windows-setup.exe.blockmap" "dist/latest.yml" --clobber --repo "${{ github.repository }}"
|
||
env:
|
||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||
|
||
- name: Verify release remains draft after artifact upload
|
||
# Re-draft immediately if publish:always exposed a partial release, then fail so latest cannot keep missing Windows assets. Use bash for $TAG expansion.
|
||
shell: bash
|
||
env:
|
||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||
TAG: ${{ needs.cut.outputs.tag }}
|
||
run: node config/scripts/assert-github-release-is-draft.mjs "${{ needs.cut.outputs.tag }}"
|
||
|
||
# Check Linux telemetry after inseparable pack/upload, then block final undraft. Reject null identity/key transport from missing configuration (#1385); prior step re-drafts premature publication.
|
||
- name: Verify telemetry constants present in app.asar
|
||
run: node config/scripts/verify-telemetry-constants.mjs
|
||
|
||
build-mac:
|
||
needs:
|
||
- cut
|
||
- create-release
|
||
- orcad-template
|
||
- release-preflight
|
||
- release-javascript
|
||
# Why not the implicit success(): a tag without the orcad template skips that job on purpose.
|
||
if: >-
|
||
!cancelled() &&
|
||
needs.cut.result == 'success' &&
|
||
needs.cut.outputs.should_release == 'true' &&
|
||
needs.create-release.result == 'success' &&
|
||
needs.release-preflight.result == 'success' &&
|
||
needs.release-javascript.result == 'success' &&
|
||
(needs.orcad-template.result == 'success' ||
|
||
(needs.orcad-template.result == 'skipped' && needs.cut.outputs.ships_orcad_template == 'false'))
|
||
# Keep every SignPath workflow job GitHub-hosted; separate release-mac-build allows Blacksmith outside Windows provenance.
|
||
runs-on: ubuntu-latest
|
||
permissions:
|
||
actions: write
|
||
contents: read
|
||
outputs:
|
||
run_id: ${{ steps.dispatch.outputs.run_id }}
|
||
workflow_sha: ${{ steps.dispatch.outputs.workflow_sha }}
|
||
run_attempt: ${{ steps.dispatch.outputs.run_attempt }}
|
||
wait_deadline_ms: ${{ steps.dispatch.outputs.wait_deadline_ms }}
|
||
steps:
|
||
- name: Checkout
|
||
uses: actions/checkout@v6
|
||
|
||
- name: Setup Node.js
|
||
uses: actions/setup-node@v6
|
||
with:
|
||
node-version-file: package.json
|
||
check-latest: true
|
||
|
||
- name: Run isolated macOS release build
|
||
id: dispatch
|
||
run: node config/scripts/run-release-mac-build-workflow.mjs dispatch
|
||
env:
|
||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||
RELEASE_MAC_BUILD_REF: ${{ github.ref_name }}
|
||
RELEASE_MAC_BUILD_RELEASE_RUN_ID: ${{ github.run_id }}
|
||
RELEASE_MAC_BUILD_JAVASCRIPT_SOURCE_SHA: ${{ needs.release-javascript.outputs.supported == 'true' && needs.release-javascript.outputs.source_sha || '' }}
|
||
RELEASE_MAC_BUILD_TAG: ${{ needs.cut.outputs.tag }}
|
||
RELEASE_MAC_BUILD_WORKFLOW: release-mac-build.yml
|
||
|
||
verify-mac-build:
|
||
needs:
|
||
- cut
|
||
- build-mac
|
||
- build
|
||
# Qualify macOS even when a sibling build fails; publication still requires every build.
|
||
if: >-
|
||
!cancelled() &&
|
||
needs.cut.result == 'success' &&
|
||
needs.cut.outputs.should_release == 'true' &&
|
||
needs.build-mac.result == 'success'
|
||
runs-on: ubuntu-latest
|
||
timeout-minutes: 95
|
||
permissions:
|
||
actions: read
|
||
contents: read
|
||
steps:
|
||
- name: Checkout
|
||
uses: actions/checkout@v6
|
||
|
||
- name: Setup Node.js
|
||
uses: actions/setup-node@v6
|
||
with:
|
||
node-version-file: package.json
|
||
check-latest: true
|
||
|
||
- name: Require the exact isolated macOS release build to succeed
|
||
run: node config/scripts/run-release-mac-build-workflow.mjs wait
|
||
env:
|
||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||
RELEASE_MAC_BUILD_REF: ${{ github.ref_name }}
|
||
RELEASE_MAC_BUILD_RELEASE_RUN_ID: ${{ github.run_id }}
|
||
RELEASE_MAC_BUILD_RUN_ID: ${{ needs.build-mac.outputs.run_id }}
|
||
RELEASE_MAC_BUILD_WORKFLOW_SHA: ${{ needs.build-mac.outputs.workflow_sha }}
|
||
RELEASE_MAC_BUILD_RUN_ATTEMPT: ${{ needs.build-mac.outputs.run_attempt }}
|
||
RELEASE_MAC_BUILD_WAIT_DEADLINE_MS: ${{ needs.build-mac.outputs.wait_deadline_ms }}
|
||
RELEASE_MAC_BUILD_TAG: ${{ needs.cut.outputs.tag }}
|
||
RELEASE_MAC_BUILD_WORKFLOW: release-mac-build.yml
|
||
|
||
publish-release:
|
||
needs:
|
||
- cut
|
||
- build
|
||
- build-mac
|
||
- verify-mac-build
|
||
- skill-sharing-linux-floor-release-gate
|
||
- skill-sharing-release-gate
|
||
- terminal-rendering-golden
|
||
# Why explicit: a skipped orcad-template (tags that predate it) would skip every job after it.
|
||
if: >-
|
||
!cancelled() &&
|
||
needs.cut.result == 'success' &&
|
||
needs.build.result == 'success' &&
|
||
needs.build-mac.result == 'success' &&
|
||
needs.verify-mac-build.result == 'success' &&
|
||
needs.skill-sharing-linux-floor-release-gate.result == 'success' &&
|
||
needs.skill-sharing-release-gate.result == 'success' &&
|
||
needs.terminal-rendering-golden.result == 'success'
|
||
runs-on: ubuntu-latest
|
||
permissions:
|
||
contents: write
|
||
steps:
|
||
- name: Checkout
|
||
uses: actions/checkout@v6
|
||
|
||
- name: Setup Node.js
|
||
uses: actions/setup-node@v6
|
||
with:
|
||
node-version-file: package.json
|
||
check-latest: true
|
||
|
||
- name: Verify release is still draft
|
||
env:
|
||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||
TAG: ${{ needs.cut.outputs.tag }}
|
||
run: |
|
||
set -euo pipefail
|
||
releases_json="$(gh api "repos/$GITHUB_REPOSITORY/releases?per_page=100")"
|
||
# Why: publish-release verifies the draft before making it visible.
|
||
draft="$(jq -e -r --arg tag "$TAG" '
|
||
map(select(.tag_name == $tag))
|
||
| if length == 1 and (.[0].draft | type) == "boolean" then (.[0].draft | tostring) else empty end
|
||
' <<<"$releases_json")" || {
|
||
echo "::error::Release $TAG was not found in the draft-aware releases list, or its draft state was missing."
|
||
exit 1
|
||
}
|
||
if [[ "$draft" != "true" ]]; then
|
||
echo "::error::Release $TAG was published before publish-release; refusing to continue."
|
||
exit 1
|
||
fi
|
||
|
||
- name: Verify release assets complete
|
||
# Only undraft after every updater manifest and referenced installer exists.
|
||
env:
|
||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||
TAG: ${{ needs.cut.outputs.tag }}
|
||
run: node config/scripts/verify-release-required-assets.mjs "$TAG"
|
||
|
||
- name: Publish release
|
||
# Reassert prerelease from tag: on 2026-04-27 builder flipped RC to false/latest and broke release math; builder output is not authoritative.
|
||
env:
|
||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||
TAG: ${{ needs.cut.outputs.tag }}
|
||
run: |
|
||
set -euo pipefail
|
||
if [[ "$TAG" == *"-rc."* ]]; then
|
||
prerelease=true
|
||
else
|
||
prerelease=false
|
||
fi
|
||
gh release edit "$TAG" \
|
||
--draft=false \
|
||
--prerelease="$prerelease" \
|
||
--repo "$GITHUB_REPOSITORY"
|
||
|
||
post-release-e2e:
|
||
needs:
|
||
- cut
|
||
- publish-release
|
||
# Why explicit: a skipped orcad-template (tags that predate it) would skip every job after it.
|
||
if: ${{ !cancelled() && needs.publish-release.result == 'success' && needs.cut.outputs.tag != '' }}
|
||
runs-on: ubuntu-latest
|
||
permissions:
|
||
actions: write
|
||
steps:
|
||
- name: Dispatch tag-scoped E2E
|
||
env:
|
||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||
TAG: ${{ needs.cut.outputs.tag }}
|
||
run: |
|
||
for attempt in 1 2 3; do
|
||
if gh workflow run e2e.yml \
|
||
--repo "$GITHUB_REPOSITORY" \
|
||
--ref "$TAG" \
|
||
--raw-field "ref=refs/tags/$TAG"; then
|
||
echo "Dispatched post-release E2E for $TAG."
|
||
exit 0
|
||
fi
|
||
[[ "$attempt" -eq 3 ]] || sleep "$((attempt * 5))"
|
||
done
|
||
echo "::warning::Failed to dispatch post-release E2E for $TAG after 3 attempts."
|
||
|
||
docs-production-dispatch:
|
||
needs:
|
||
- cut
|
||
- publish-release
|
||
# A release created with GITHUB_TOKEN does not reliably emit a release
|
||
# event to other workflows. Dispatch the trusted default-branch workflow;
|
||
# it validates and checks out the released tag before deploying.
|
||
# Why explicit: a skipped orcad-template (tags that predate it) would skip every job after it.
|
||
if: ${{ !cancelled() && needs.publish-release.result == 'success' && needs.cut.outputs.tag != '' }}
|
||
runs-on: ubuntu-latest
|
||
permissions:
|
||
actions: write
|
||
steps:
|
||
- name: Dispatch docs deployment for stable desktop tags
|
||
env:
|
||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||
TAG: ${{ needs.cut.outputs.tag }}
|
||
DEFAULT_BRANCH: ${{ github.event.repository.default_branch }}
|
||
run: |
|
||
set -euo pipefail
|
||
if [[ ! "$TAG" =~ ^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$ ]]; then
|
||
echo "Skipping docs deployment for non-stable tag $TAG."
|
||
exit 0
|
||
fi
|
||
for attempt in 1 2 3; do
|
||
if gh workflow run docs.yml \
|
||
--repo "$GITHUB_REPOSITORY" \
|
||
--ref "$DEFAULT_BRANCH" \
|
||
--field "tag=$TAG"; then
|
||
echo "Dispatched docs deployment for $TAG."
|
||
exit 0
|
||
fi
|
||
[[ "$attempt" -eq 3 ]] || sleep "$((attempt * 5))"
|
||
done
|
||
echo "::error::Failed to dispatch docs deployment for $TAG after 3 attempts."
|
||
exit 1
|
||
|
||
homebrew-bump-published-rc-draft:
|
||
needs:
|
||
- cut
|
||
# Why: publish-complete-draft-releases can expose a recovered RC without
|
||
# running the build/publish jobs; still advance the RC cask to that tag.
|
||
if: ${{ needs.cut.outputs.latest_published_rc_tag != '' }}
|
||
uses: ./.github/workflows/homebrew-bump.yml
|
||
with:
|
||
tag: ${{ needs.cut.outputs.latest_published_rc_tag }}
|
||
secrets: inherit
|
||
|
||
homebrew-bump:
|
||
needs:
|
||
- cut
|
||
- publish-release
|
||
# Why explicit: a skipped orcad-template (tags that predate it) would skip every job after it.
|
||
if: ${{ !cancelled() && needs.publish-release.result == 'success' && needs.cut.outputs.tag != '' && startsWith(needs.cut.outputs.tag, 'v') }}
|
||
uses: ./.github/workflows/homebrew-bump.yml
|
||
with:
|
||
tag: ${{ needs.cut.outputs.tag }}
|
||
secrets: inherit
|