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/[~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 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:-}" # 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. 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" # Electron and builder releases fix what this downloads; the lockfile churns far more often. - name: Resolve electron-builder download versions id: eb-versions shell: bash run: node -p "const d = require('./package.json').devDependencies; 'versions=electron' + d.electron + '-builder' + d['electron-builder']" >> "$GITHUB_OUTPUT" # Cache Electron and builder downloads (winCodeSign/NSIS/Squirrel/AppImage), saving ~30–90s per job. - name: Cache electron-builder downloads uses: actions/cache@v5 with: path: ${{ matrix.eb_cache_path }} key: electron-builder-${{ matrix.platform }}-${{ steps.eb-versions.outputs.versions }} restore-keys: | electron-builder-${{ matrix.platform }}- # PRs cache tools separately from Electron; identical paths preserve their cache version. - name: Seed shared Linux packaging downloads if: matrix.platform == 'linux-x64' && github.ref == 'refs/heads/main' uses: actions/cache@v5 with: path: ~/.cache/electron-builder key: electron-builder-linux-${{ steps.eb-versions.outputs.versions }} # The release cache above restores the same tools, so this only needs to save on a miss. lookup-only: true # Retry transient Electron CDN 504s during install; keep host-scoped native dependencies for this platform’s package. - name: Install dependencies uses: nick-fields/retry@v4 with: timeout_minutes: 10 max_attempts: 3 retry_wait_seconds: 30 command: pnpm install --frozen-lockfile # Build required out/mobile-web from mobile’s React Native/Expo dependencies. - uses: ./.github/actions/install-mobile-dependencies # Install AT-SPI runtime bindings for Linux provider verification and rpm for builder’s fpm backend; stock Ubuntu lacks them. - name: Install Linux computer-use provider dependencies if: runner.os == 'Linux' run: sudo apt-get update && sudo apt-get install -y python3-gi gir1.2-atspi-2.0 at-spi2-core xclip xdotool rpm # Derive literal stable/rc telemetry identity from strict tag shape; refuse unclassified builds rather than ship silent null identity (#1385 follow-up). - name: Classify release tag for telemetry build identity id: tag-classify shell: bash env: TAG: ${{ needs.cut.outputs.tag }} run: | set -euo pipefail # Suffixed vX.Y.Z-rc.N.perf builds keep the same rc-channel telemetry identity. if [[ "$TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+-rc\.[0-9]+(\.[0-9A-Za-z]+)?$ ]]; then identity=rc elif [[ "$TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then identity=stable else echo "::error::Tag $TAG does not match stable or rc pattern; refusing to build official artifact" exit 1 fi echo "identity=$identity" >>"$GITHUB_OUTPUT" echo "Classified $TAG as $identity" # Require cargo before builder invokes the packaged Rust launcher hook. - name: Ensure the Rust toolchain for the Windows CLI launcher if: matrix.platform == 'win' shell: pwsh run: | if (-not (Get-Command cargo -ErrorAction SilentlyContinue)) { rustup toolchain install stable --profile minimal rustup default stable } cargo --version # Consumers verify the same official build configuration before restoring the bundle. - name: Download release JavaScript if: needs.release-javascript.outputs.supported == 'true' uses: actions/download-artifact@v8 with: name: release-javascript path: .build/release-javascript - name: Build app shell: bash run: | if [ "$SHARED_JAVASCRIPT" = true ]; then 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 ") return } $signature = Get-AuthenticodeSignature -FilePath $_.FullName if ($signature.Status -eq 'Valid') { $skipped.Add("$relative ") 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)) { "" } 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 /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) { '' } 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)) { "" } 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) { '' } 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) { '' } 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" } } $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