mirror of
https://github.com/stablyai/orca.git
synced 2026-10-20 16:03:20 +00:00
* fix(skills): advance the release ledger at the cut so shipped revisions freeze
#10340 made the released-skill registry a function of the committed ledger
instead of a git tag walk, and #10460 reverted the cut step that advances that
ledger because it violated the #9119 contract (a version-only cut must not
regenerate or stage the content-addressed skill artifacts). Both were right;
the result is a ledger that never advances.
generate-skill-bundle-manifest.mjs:390 derives releasedCount solely from
release-mapping.json and :461 assigns a changed skill releaseRevision =
releasedCount + 1, while :518 protects only committedReleasedCounts[name] —
so index releasedCount is unprotected. A tag ships that tail revision, nothing
records it, and the next skill change rebuilds the same revision number over
different bytes. Installs carrying the shipped digest then match no snapshot
and degrade to unrecognized, which cannot be updated.
Restore the advance in a form the #9119 contract can keep enforcing:
--release now verifies that current-manifest.json and snapshot-registry.json
already match the ref being tagged, appends the mapping row, and writes only
release-mapping.json. The cut stages just that file, so it still cannot move a
content-addressed artifact — the failure #9119 guarded against — and now fails
loudly instead of recording a revision the tag does not ship.
The contract test is narrowed to match: it asserts the cut runs --release
(never --write) and stages exactly package.json and release-mapping.json.
* test(release-cut): close the staging bypasses the narrowed gate left open
The narrowed contract test anchored its `git add` scan to line start and
only inspected staged paths, so three ways to reintroduce #9119 stayed
green: a `git add` chained after `&&`, a write that never calls `git add`
at all, and `pnpm run generate:skill-bundle-manifest` — the package.json
alias for `--write`, which the hyphenated ban never matched. That last one
also passed the pre-#10460 assertions, so it was never covered.
Drop the anchor, require every `resources/skills` mention in the step to
be exactly what is staged, and ban the alias and `commit -a`. Comments are
stripped first so prose cannot trip a ban. Verified each bypass fails and
the real workflow passes.
* fix(release-cut): make the new provenance failure actionable to an operator
Verifying the content-addressed artifacts is the only new way the cut can
block, and it fails inside a step named "Bump package.json and tag" with a
lint-shaped message. That names the files and the command but not the two
things the operator needs: the regeneration has to land on main, and the
cut is safe to re-run afterwards. Say so.
Also pin down why assertReleasedHistoryPreserved takes the pre-append
mapping. It pairs with artifacts.releasedSnapshotCounts, which seeding
fixed before the row existed; handing it the post-append mapping makes
every cut throw "Released snapshot history is incomplete", which points
at tag fetching rather than the real cause. Nothing enforces the pairing.
* test(release-cut): gate the whole cut job, not just the bump step
Round-2 review defeated the previous gate twice, both proved by running
the full contract file green with #9119 reintroduced.
Every step in the cut job shares one workspace and one index, but the
contract test only inspected `Bump package.json and tag`. A step inserted
earlier could run --write and `git add resources/skills`, and the bump
step's own commit swept it into the version commit and the tag. Assert
job-wide instead: only the bump step may name the directory, and no step
may regenerate under either the flag or its package.json alias. That lives
in the generator suite because the contract file is at its max-lines cap.
Two regexes were also evadable. The mention scan required a trailing
slash, so a path held in a variable was invisible; it now matches the
directory itself. The `commit -a` ban matched nothing at all — `commit\s`
ate the only separator, so `-a`, `-am`, and `--all` all survived while
only a trailing `-a` was caught. `--allow-empty` stays allowed.
* fix(release-cut): assert the index, not the workflow text, before committing
Round-3 review defeated the job-wide grep three ways, each proved by
running both test files green with #9119 reintroduced into the tagged
commit: an `env:` block holding `--write` and `resources/skills`, a
composite action whose steps the workflow never spells out, and plain
shell concatenation (`root=resources; leaf=skills`).
Grepping shell source for path literals is inherently evadable, and the
previous fix only relocated round-2's variable-indirection hole one step
over. Move the invariant to where it cannot be dodged: immediately before
committing, the cut diffs its own index and refuses anything that is not
package.json or the release-mapping row. That does not care which step
staged what, or how the path was spelled.
The workflow grep stays as a cheap tripwire for literal spellings, now
paired with a positive assertion that the index guard exists and precedes
the commit — indirection cannot hide a missing guard. Mention matching
dedupes and trims quotes, since the guard names the row a second time.
* fix(release-cut): match the staged-path allowlist literally
`grep -vx` treats its patterns as regexes, so the `.` in `package.json`
matched any character: a staged `packageXjson` or a
`resources/skills/release-mappingXjson` was silently accepted by the
index guard. Verified both slip through `-vx` and are caught by `-vxF`.
Exercised the guard against a legitimate cut, an empty index, a staged
content-addressed artifact, paths containing a space and a non-ASCII
character (git quotes the latter, so it fails closed), and a staged
deletion. Only the two allowed paths pass.
* test(release-cut): assert the index guard aborts, not just that it exists
The positive assertion pinned the guard's shape and its position before
the commit, but not its effect: replacing `exit 1` with `:` left both
test files green while the cut logged the error and shipped the artifact
anyway. That is the same failure this whole gate keeps having — asserting
the shape of a defense rather than what it does.
Pin the abort too. Verified the neutered guard now fails the suite.
* test(release-cut): scope the abort check and catch clustered commit flags
Two holes in the guards this PR added, both in the same shape-not-effect
class the previous commit was meant to close.
The abort assertion's lazy match was not scoped to the guard's own block,
so it could borrow an `exit 1` from any later `if ... fi` in the step.
Degrading the guard to a warning while adding a plausible HEAD
precondition left every test green. Stop the match at the guard's `fi`.
The `commit -a` ban only matched when `a` led the flag cluster, so `-vam`,
`-va`, `-qam` and `-sam` all survived. That matters more than it looks:
`commit -a` stages at commit time, after the index guard has already
inspected a clean index, so it is the one way to defeat that guard. Match
`a` anywhere in a short-flag cluster; `--allow-empty` and `--amend` stay
allowed. Verified both mutants now fail.
* fix(release-cut): validate the commit, not the index, before tagging
The index guard asserted the wrong thing. `git commit` has a family of
forms that commit the working tree rather than the index — `-a`, `-i`,
`--only`, and a bare pathspec — so a rogue earlier step could leave
regenerated artifacts unstaged and any of those forms would carry them
into the tagged commit while the guard saw a clean index and passed.
Reproduced end to end: `git commit -i resources` put current-manifest.json
and snapshot-registry.json in the tag with all gates green, and
`--only resources` additionally dropped package.json from the tag.
Banning those flags one by one is the same enumeration game the earlier
rounds kept losing. Assert the outcome instead: after committing and
before tagging, diff-tree HEAD and refuse anything that is not
package.json or the release-mapping row. That is indifferent to which
step staged what and to how the commit was spelled.
Verified the whole family is now blocked (-i, --only, -a, -am, -vam,
pathspec, and an alias expanding to `commit -i`), that a stock commit and
an --allow-empty re-cut still pass, and that deleting, neutering,
un-anchoring, or relocating the guard each fails the suite.
* fix(release-cut): make the commit guard fail closed on a merge commit
Plain `git diff-tree` prints nothing for a merge commit, so the guard
would have passed silently instead of failing closed — the one direction
that matters on a release path. `-m --first-parent` reports the diff
against the first parent; verified byte-identical output for an ordinary
commit and still empty for the `--allow-empty` re-cut, so nothing else
changes. Not reachable today (nothing in the cut job creates a merge, and
npm version has no lifecycle hooks defined), but the failure mode is a
guard that looks like it ran.
Pin the flags in the assertion too, so neither dropping -m nor slipping in
a `--diff-filter` can weaken it without failing the suite.
(cherry picked from commit 7a01910f20)
1702 lines
78 KiB
YAML
1702 lines
78 KiB
YAML
name: Cut Release
|
|
|
|
# Why: single entry point for manually cutting releases.
|
|
# Replaces the old local `pnpm release:*` scripts and the standalone scheduled
|
|
# RC workflow so releases are always reproducible from CI and can never be
|
|
# accidentally tagged against an uncommitted or non-main working tree.
|
|
#
|
|
# Flow:
|
|
# 1. Resolve `ref` to a SHA.
|
|
# 2. Read the latest stable release from GitHub.
|
|
# 3. Compute the next version from `kind` (rc | patch | minor | major).
|
|
# 4. For stable kinds, REFUSE if the new version is <= the latest stable.
|
|
# This is the only guard electron-updater actually needs — it compares
|
|
# semver within a channel, so a regressing "latest" is the one thing
|
|
# that breaks auto-update for fresh installs.
|
|
# 5. Write package.json, commit (detached), tag, push tag.
|
|
# 6. If ref was the tip of origin/main, fast-forward main to include the
|
|
# version-bump commit so developers see the right version locally.
|
|
# 7. Build and publish artifacts from the tag.
|
|
|
|
on:
|
|
workflow_dispatch:
|
|
inputs:
|
|
kind:
|
|
description: Release kind
|
|
required: true
|
|
type: choice
|
|
default: rc
|
|
options:
|
|
- rc
|
|
- patch
|
|
- minor
|
|
- major
|
|
ref:
|
|
description: Branch, tag, or SHA to release from (default main)
|
|
required: false
|
|
type: string
|
|
default: main
|
|
dry_run:
|
|
description: Validate an RC release cut without creating a tag
|
|
required: false
|
|
default: false
|
|
type: boolean
|
|
version_suffix:
|
|
description: Extra prerelease identifier appended to an rc version (e.g. "perf" -> 1.2.3-rc.4.perf). rc kind only.
|
|
required: false
|
|
type: string
|
|
default: ''
|
|
version:
|
|
description: Exact version to cut (e.g. 1.4.155 or 1.4.155-rc.0), 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.
|
|
required: false
|
|
type: string
|
|
default: ''
|
|
|
|
permissions:
|
|
contents: write
|
|
|
|
concurrency:
|
|
group: release-cut
|
|
cancel-in-progress: false
|
|
|
|
jobs:
|
|
cut:
|
|
# Why: this job bumps package.json and fast-forwards main. On a fork with
|
|
# Actions enabled, the scheduled cut would run against the fork's main and
|
|
# diverge it (version line) every slot, conflicting every PR back upstream.
|
|
# Gate to the canonical repo so the workflow no-ops on forks.
|
|
if: github.repository == 'stablyai/orca'
|
|
runs-on: ubuntu-latest
|
|
timeout-minutes: 15
|
|
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 }}
|
|
steps:
|
|
# Surfaces workflow_dispatch inputs as a table in the job summary
|
|
# (kind, ref, dry_run, version_suffix) so runs are easy to audit.
|
|
- uses: m-s-abeer/update-gha-summary-with-workflow-inputs@v1
|
|
|
|
- name: Checkout ref
|
|
uses: actions/checkout@v6
|
|
with:
|
|
ref: ${{ github.event_name == 'schedule' && 'main' || inputs.ref }}
|
|
fetch-depth: 0
|
|
|
|
- name: Setup Node.js
|
|
uses: actions/setup-node@v6
|
|
with:
|
|
node-version-file: package.json
|
|
|
|
- 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
|
|
run: |
|
|
sha="$(git rev-parse HEAD)"
|
|
echo "sha=$sha" >>"$GITHUB_OUTPUT"
|
|
|
|
# Why: only push the version-bump commit back to main when the
|
|
# caller is releasing the exact tip of main. For any older or
|
|
# off-main ref we leave main alone and only publish the tag.
|
|
git fetch origin main --quiet
|
|
main_sha="$(git rev-parse origin/main)"
|
|
if [[ "$sha" == "$main_sha" ]]; then
|
|
echo "push_main=true" >>"$GITHUB_OUTPUT"
|
|
else
|
|
echo "push_main=false" >>"$GITHUB_OUTPUT"
|
|
fi
|
|
|
|
- name: Compute RC slot
|
|
id: slot
|
|
run: |
|
|
slot=$(TZ=America/Los_Angeles date '+%Y-%m-%d-%H')
|
|
echo "value=$slot" >>"$GITHUB_OUTPUT"
|
|
|
|
- name: Validate PT release window
|
|
id: window
|
|
env:
|
|
EVENT_NAME: ${{ github.event_name }}
|
|
run: |
|
|
if [[ "$EVENT_NAME" != "schedule" ]]; then
|
|
echo "allowed=true" >>"$GITHUB_OUTPUT"
|
|
echo "reason=manual" >>"$GITHUB_OUTPUT"
|
|
exit 0
|
|
fi
|
|
|
|
pt_hour=$(TZ=America/Los_Angeles date '+%H')
|
|
pt_minute=$(TZ=America/Los_Angeles date '+%M')
|
|
|
|
# Why: GitHub may deliver a scheduled event long after the intended
|
|
# time, so delayed 4:16 AM runs must not cut the 3:00 AM release.
|
|
if [[ "$pt_hour" == "03" || "$pt_hour" == "15" ]]; then
|
|
echo "allowed=true" >>"$GITHUB_OUTPUT"
|
|
echo "reason=target_hour:${pt_hour}:${pt_minute}" >>"$GITHUB_OUTPUT"
|
|
exit 0
|
|
fi
|
|
|
|
echo "allowed=false" >>"$GITHUB_OUTPUT"
|
|
echo "reason=outside_target_hour:${pt_hour}:${pt_minute}" >>"$GITHUB_OUTPUT"
|
|
|
|
- name: Skip if this PT release window already ran
|
|
id: existing
|
|
if: github.event_name == 'schedule' && steps.window.outputs.allowed == 'true'
|
|
run: |
|
|
# Why: scheduled runs retry inside each target hour, so make the
|
|
# schedule idempotent by embedding a slot marker in the release commit.
|
|
if git log origin/main --grep="\\[rc-slot:${{ steps.slot.outputs.value }}\\]" -n 1 --format=%H | grep -q .; then
|
|
echo "already_ran=true" >>"$GITHUB_OUTPUT"
|
|
exit 0
|
|
fi
|
|
|
|
# Why: this preserves dedupe across the older scheduled workflow's
|
|
# first runs, before all RC cuts shared release-cut's slot marker.
|
|
latest_rc_tag="$(git for-each-ref --sort=-creatordate --format='%(refname:short) %(creatordate:iso-strict)' 'refs/tags/v*-rc.*' | head -n 1)"
|
|
if [[ -n "$latest_rc_tag" ]]; then
|
|
latest_rc_tag_name="${latest_rc_tag%% *}"
|
|
latest_rc_tag_date="${latest_rc_tag#* }"
|
|
latest_rc_slot="$(TZ=America/Los_Angeles date -d "$latest_rc_tag_date" '+%Y-%m-%d-%H')"
|
|
|
|
if [[ "$latest_rc_slot" == "${{ steps.slot.outputs.value }}" ]]; then
|
|
echo "already_ran=true" >>"$GITHUB_OUTPUT"
|
|
echo "reason=latest_rc_tag:$latest_rc_tag_name" >>"$GITHUB_OUTPUT"
|
|
exit 0
|
|
fi
|
|
fi
|
|
|
|
echo "already_ran=false" >>"$GITHUB_OUTPUT"
|
|
|
|
- name: Dry run summary
|
|
if: github.event_name == 'workflow_dispatch' && inputs.dry_run
|
|
run: |
|
|
echo "Dry run only."
|
|
echo "Current PT slot: ${{ steps.slot.outputs.value }}"
|
|
echo "Window allowed: ${{ steps.window.outputs.allowed }}"
|
|
echo "Window reason: ${{ steps.window.outputs.reason }}"
|
|
echo "Already ran this slot: ${{ steps.existing.outputs.already_ran }}"
|
|
echo "Reason: ${{ steps.existing.outputs.reason }}"
|
|
|
|
- name: Skip summary
|
|
if: steps.window.outputs.allowed != 'true' || steps.existing.outputs.already_ran == 'true'
|
|
run: |
|
|
echo "Skipping release cut."
|
|
echo "Current PT slot: ${{ steps.slot.outputs.value }}"
|
|
echo "Window reason: ${{ steps.window.outputs.reason }}"
|
|
echo "Already ran this slot: ${{ steps.existing.outputs.already_ran }}"
|
|
echo "Reason: ${{ steps.existing.outputs.reason }}"
|
|
|
|
- name: Publish complete release-cut RC drafts from prior runs
|
|
id: publish_drafts
|
|
# Why: a manual RC dispatch should unstick any complete RC draft before
|
|
# deciding whether to cut another tag.
|
|
if: steps.window.outputs.allowed == 'true' && !(github.event_name == 'workflow_dispatch' && inputs.dry_run)
|
|
env:
|
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
run: node config/scripts/publish-complete-draft-releases.mjs
|
|
|
|
- name: Compute next version
|
|
id: version
|
|
# Why: if an RC run only had complete drafts to publish, stop there
|
|
# instead of immediately cutting another RC after the recovered one.
|
|
# Stable dispatches should still cut the requested stable release.
|
|
if: steps.window.outputs.allowed == 'true' && steps.existing.outputs.already_ran != 'true' && !(github.event_name == 'workflow_dispatch' && inputs.dry_run) && !((github.event_name == 'schedule' || inputs.kind == 'rc') && steps.publish_drafts.outputs.published_count != '0' && steps.publish_drafts.outputs.skipped_count == '0')
|
|
env:
|
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
KIND: ${{ github.event_name == 'schedule' && 'rc' || inputs.kind }}
|
|
VERSION_SUFFIX: ${{ github.event_name == 'schedule' && '' || inputs.version_suffix }}
|
|
EXPLICIT_VERSION: ${{ github.event_name == 'schedule' && '' || inputs.version }}
|
|
run: |
|
|
set -euo pipefail
|
|
|
|
# Latest stable release tag, picked by *tag shape* and semver max:
|
|
# - must start with `v<digit>` (desktop convention, e.g. v1.3.32)
|
|
# - must NOT contain `-rc.` (not a prerelease)
|
|
#
|
|
# Why not the GitHub `isPrerelease` flag: electron-builder's publish
|
|
# step has flipped that flag back to `false` on RC releases before
|
|
# (v1.3.22-rc.2 on 2026-04-27 briefly became "latest" on GitHub and
|
|
# poisoned the math here). Tag format is authoritative.
|
|
#
|
|
# Why the `^v[0-9]` prefix: other products shipped from this repo
|
|
# use their own prefixes (e.g. `mobile-v0.0.1`). Without the prefix
|
|
# gate the latest mobile release would be selected as "latest
|
|
# stable", then `strip_pre()` would reduce `mobile-v0.0.1` to
|
|
# `mobile`, `Number("mobile")` → NaN → 0, and a patch-bump would
|
|
# produce `0.0.1` — exactly the wedge on 2026-05-04 (run
|
|
# 25304336767). Any non-desktop tag shape must be excluded here.
|
|
#
|
|
# Why not `gh release list` order: GitHub can list a newer published
|
|
# stable after older releases. On 2026-06-04, v1.4.44 existed but
|
|
# the list returned v1.4.42 first, causing a manual RC cut to reopen
|
|
# the already-shipped 1.4.43 series as v1.4.43-rc.0.
|
|
latest_stable="$(node config/scripts/latest-stable-release.mjs)"
|
|
latest_stable="${latest_stable#v}"
|
|
echo "Latest stable: ${latest_stable:-<none>}"
|
|
|
|
# Strip any prerelease suffix before numeric math. Without this,
|
|
# `Number("1-rc")` returns NaN and `(NaN||0)+1` silently collapses
|
|
# to 1 — exactly the path that produced v1.3.1-rc.4 on 2026-04-27
|
|
# when latest_stable was misread as a prerelease tag.
|
|
strip_pre() { echo "${1%%-*}"; }
|
|
|
|
semver_gt() {
|
|
# returns 0 if $1 > $2 by semver rules (ignoring prerelease)
|
|
node -e '
|
|
const a = process.argv[1].split(".").map(Number);
|
|
const b = process.argv[2].split(".").map(Number);
|
|
for (let i = 0; i < 3; i++) {
|
|
if ((a[i]||0) > (b[i]||0)) process.exit(0);
|
|
if ((a[i]||0) < (b[i]||0)) process.exit(1);
|
|
}
|
|
process.exit(1);
|
|
' "$(strip_pre "$1")" "$(strip_pre "$2")"
|
|
}
|
|
|
|
bump() {
|
|
# $1=version, $2=level (patch|minor|major)
|
|
node -e '
|
|
const v = process.argv[1].split(".").map(Number);
|
|
const level = process.argv[2];
|
|
if (level === "major") console.log(`${(v[0]||0)+1}.0.0`);
|
|
else if (level === "minor") console.log(`${v[0]||0}.${(v[1]||0)+1}.0`);
|
|
else console.log(`${v[0]||0}.${v[1]||0}.${(v[2]||0)+1}`);
|
|
' "$(strip_pre "$1")" "$2"
|
|
}
|
|
|
|
highest_rc_for_base() {
|
|
node config/scripts/release-rc-history.mjs "$1"
|
|
}
|
|
|
|
current_package_stable() {
|
|
node -e '
|
|
const { version } = require("./package.json");
|
|
if (/^[0-9]+\.[0-9]+\.[0-9]+$/.test(version)) console.log(version);
|
|
'
|
|
}
|
|
|
|
tag_matches_current_ref() {
|
|
local tag="$1"
|
|
local tag_commit
|
|
local head_commit
|
|
if ! tag_commit="$(git rev-parse "${tag}^{}" 2>/dev/null)"; then
|
|
return 1
|
|
fi
|
|
head_commit="$(git rev-parse HEAD)"
|
|
if [[ "$tag_commit" == "$head_commit" ]]; then
|
|
return 0
|
|
fi
|
|
|
|
local tag_parent
|
|
tag_parent="$(git rev-parse "${tag_commit}^" 2>/dev/null)" || return 1
|
|
[[ "$tag_parent" == "$head_commit" ]]
|
|
}
|
|
|
|
release_draft_state() {
|
|
# Prints: true, false, or missing.
|
|
local tag="$1"
|
|
local state_file="$RUNNER_TEMP/release-state-${tag//[^A-Za-z0-9_.-]/_}"
|
|
if gh release view "$tag" \
|
|
--repo "$GITHUB_REPOSITORY" \
|
|
--json isDraft \
|
|
--jq '.isDraft' >"$state_file" 2>/dev/null; then
|
|
cat "$state_file"
|
|
else
|
|
echo "missing"
|
|
fi
|
|
}
|
|
|
|
recover_unpublished_tag() {
|
|
local tag="$1"
|
|
local reason="$2"
|
|
local release_state
|
|
release_state="$(release_draft_state "$tag")"
|
|
case "$release_state" in
|
|
missing|true)
|
|
if ! tag_matches_current_ref "$tag"; then
|
|
echo "::warning::Tag $tag already exists but was cut from a different release ref ($reason) - cutting the next version instead of reusing stale artifacts."
|
|
return 1
|
|
fi
|
|
echo "::warning::Tag $tag already exists but has no published release ($reason) - recovering by re-dispatching the release build against the existing tag."
|
|
echo "recovered_tag=$tag" >>"$GITHUB_OUTPUT"
|
|
echo "recovered=true" >>"$GITHUB_OUTPUT"
|
|
exit 0
|
|
;;
|
|
false)
|
|
return 1
|
|
;;
|
|
*)
|
|
echo "::error::Unexpected release state for $tag: $release_state" >&2
|
|
exit 1
|
|
;;
|
|
esac
|
|
}
|
|
|
|
# Fresh repo fallback so the math below never divides by zero.
|
|
if [[ -z "$latest_stable" ]]; then
|
|
latest_stable="0.0.0"
|
|
fi
|
|
|
|
package_stable="$(current_package_stable)"
|
|
if [[ -n "$package_stable" ]]; then
|
|
# Why: if a stable release is deleted after its version-bump commit
|
|
# reached main, GitHub's release list regresses. package.json is the
|
|
# floor for the current ref so the next cut cannot reuse an older
|
|
# stable number just because the public release was nuked.
|
|
if semver_gt "$package_stable" "$latest_stable"; then
|
|
# Skip floor-tag recovery when an explicit version is requested:
|
|
# recover_unpublished_tag can exit 0, which would recover the
|
|
# package-floor tag instead of cutting the requested version —
|
|
# defeating the very rollback scenario the override exists for.
|
|
# We still raise latest_stable to the floor below so the explicit
|
|
# version is gated against it; the collision recovery for the
|
|
# requested tag runs later.
|
|
if [[ "$KIND" != "rc" && -z "${EXPLICIT_VERSION:-}" ]]; then
|
|
package_tag="v$package_stable"
|
|
if git rev-parse "$package_tag" >/dev/null 2>&1; then
|
|
recover_unpublished_tag "$package_tag" "current ref stable tag is newer than latest published stable" || true
|
|
fi
|
|
fi
|
|
|
|
echo "Stable floor from package.json: $package_stable"
|
|
latest_stable="$package_stable"
|
|
fi
|
|
fi
|
|
|
|
# Explicit version override (manual dispatch only).
|
|
#
|
|
# Why: kind-based math derives the next number from the latest
|
|
# *published* stable. When a shipped stable is deleted (e.g. a
|
|
# rolled-back 1.4.154), the release list regresses to the prior
|
|
# stable, so a kind cut recomputes a number at or below the nuked one
|
|
# and strands every client that already installed the deleted build.
|
|
# The package.json floor above only recovers this when the deleted
|
|
# version's bump commit is on the ref being cut, which a hotfix cut
|
|
# from an older RC ref does not carry. An explicit version lets a
|
|
# human assert the exact target (e.g. leapfrog to 1.4.155); the
|
|
# updater-safety gate and tag-collision recovery below still apply.
|
|
new=""
|
|
if [[ -n "${EXPLICIT_VERSION:-}" ]]; then
|
|
explicit="${EXPLICIT_VERSION#v}"
|
|
if [[ ! "$explicit" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-rc\.[0-9]+)?$ ]]; then
|
|
echo "::error::version must be X.Y.Z or X.Y.Z-rc.N, got: $EXPLICIT_VERSION" >&2
|
|
exit 1
|
|
fi
|
|
# Same updater-safety gate the kind path enforces: stable line must
|
|
# strictly increase over the latest published stable (prerelease
|
|
# identifiers ignored for the comparison).
|
|
if ! semver_gt "$explicit" "$latest_stable"; then
|
|
echo "::error::Refusing explicit version $explicit: not greater than latest stable $latest_stable." >&2
|
|
exit 1
|
|
fi
|
|
new="$explicit"
|
|
echo "Explicit version override: $new"
|
|
fi
|
|
|
|
if [[ -z "$new" ]]; then
|
|
case "$KIND" in
|
|
rc)
|
|
# Why: RCs always stabilize the *next* patch after whatever
|
|
# is currently published as stable. Earlier logic tried to
|
|
# "continue the current series" by reading the highest git
|
|
# tag, which silently reopened a series that had already
|
|
# shipped (e.g. cutting v1.3.21-rc.7 after v1.3.21 stable
|
|
# was out). Anchoring to latest_stable + patch eliminates
|
|
# that class of bug; minor/major RCs are cut by running
|
|
# that stable kind first.
|
|
base="$(bump "$latest_stable" patch)"
|
|
highest_rc="$(highest_rc_for_base "$base")"
|
|
if [[ -z "$highest_rc" ]]; then
|
|
new="${base}-rc.0"
|
|
else
|
|
existing_rc_tag="v${base}-rc.${highest_rc}"
|
|
# Why: a failed or GitHub-stuck run can leave the highest RC
|
|
# tag attached to a draft/missing release. Resume only when it
|
|
# was cut from this ref; stale attempts advance to rc.N+1.
|
|
if git rev-parse "$existing_rc_tag" >/dev/null 2>&1; then
|
|
recover_unpublished_tag "$existing_rc_tag" "latest RC in series" || true
|
|
fi
|
|
new="${base}-rc.$((highest_rc + 1))"
|
|
fi
|
|
if [[ -n "${VERSION_SUFFIX:-}" ]]; then
|
|
# Why a dot-appended identifier (rc.N.perf): it sorts just
|
|
# above its own base rc.N but BELOW rc.N+1, so suffixed side-
|
|
# branch builds never outrank the main RC series and cannot
|
|
# hijack the update channel; clients find them by matching the
|
|
# identifier ("perf") in the prerelease components.
|
|
if [[ ! "$VERSION_SUFFIX" =~ ^[0-9A-Za-z]+$ ]]; then
|
|
echo "::error::version_suffix must be alphanumeric, got: $VERSION_SUFFIX" >&2
|
|
exit 1
|
|
fi
|
|
new="${new}.${VERSION_SUFFIX}"
|
|
fi
|
|
;;
|
|
patch|minor|major)
|
|
new="$(bump "$latest_stable" "$KIND")"
|
|
# Updater-safety gate: stable must strictly increase.
|
|
if ! semver_gt "$new" "$latest_stable"; then
|
|
echo "::error::Refusing to cut $KIND $new: not greater than latest stable $latest_stable." >&2
|
|
exit 1
|
|
fi
|
|
|
|
# Why: a stale orphan stable tag can exist from an older release
|
|
# ref after main has moved on. If it cannot be recovered for the
|
|
# current ref, advance to the next stable version instead of
|
|
# wedging every future patch cut on the same collision.
|
|
for _ in {1..100}; do
|
|
candidate_tag="v$new"
|
|
if ! git rev-parse "$candidate_tag" >/dev/null 2>&1; then
|
|
break
|
|
fi
|
|
|
|
candidate_release_state="$(release_draft_state "$candidate_tag")"
|
|
case "$candidate_release_state" in
|
|
missing|true)
|
|
recover_unpublished_tag "$candidate_tag" "tag collision" || true
|
|
new="$(bump "$new" "$KIND")"
|
|
;;
|
|
false)
|
|
echo "::error::Tag $candidate_tag already exists with a published release. Refusing to skip over a shipped version." >&2
|
|
exit 1
|
|
;;
|
|
*)
|
|
echo "::error::Unexpected release state for $candidate_tag: $candidate_release_state" >&2
|
|
exit 1
|
|
;;
|
|
esac
|
|
done
|
|
;;
|
|
*)
|
|
echo "::error::Unknown kind: $KIND" >&2
|
|
exit 1
|
|
;;
|
|
esac
|
|
fi
|
|
|
|
# Orphan-tag recovery.
|
|
#
|
|
# Why: if a previous cut pushed the tag but was cancelled (or the
|
|
# dependent release build jobs otherwise failed to start) before the
|
|
# GitHub Release was published, the tag now exists on the remote
|
|
# but "latest stable" still points at the prior version. Every
|
|
# subsequent patch cut then recomputes the same version and dies
|
|
# on "Tag already exists." This exact sequence wedged the cut
|
|
# pipeline on 2026-05-01 when v1.3.26 was pushed by a cancelled
|
|
# run (25237882049) — every patch cut after that rehit the same
|
|
# tag for hours until the orphan release was dispatched by hand.
|
|
#
|
|
# Recovery policy: if the tag exists AND no GitHub release has
|
|
# been published for it (draft-or-absent both count as "not
|
|
# shipped"), treat this as a resumable state: emit the existing
|
|
# tag as the job output so the downstream release build jobs run
|
|
# against it and finishes what the earlier attempt started. The
|
|
# bump/commit/push steps are skipped in that case — there is
|
|
# nothing to bump; the tag is already on the remote.
|
|
#
|
|
# Refuse collisions only when the tag *and* a published release
|
|
# already exist — that's a real conflict (someone tagged manually
|
|
# over a shipped version) and needs human attention.
|
|
if git rev-parse "v$new" >/dev/null 2>&1; then
|
|
recover_unpublished_tag "v$new" "tag collision" || {
|
|
echo "::error::Tag v$new already exists and cannot be recovered for this ref. Refusing to re-cut over an existing version." >&2
|
|
exit 1
|
|
}
|
|
fi
|
|
|
|
echo "version=$new" >>"$GITHUB_OUTPUT"
|
|
echo "Next version: $new"
|
|
|
|
- name: Bump package.json and tag
|
|
id: tag
|
|
if: steps.version.outputs.version != '' && steps.version.outputs.recovered != 'true'
|
|
env:
|
|
EVENT_NAME: ${{ github.event_name }}
|
|
SLOT: ${{ steps.slot.outputs.value }}
|
|
VERSION: ${{ steps.version.outputs.version }}
|
|
run: |
|
|
set -euo pipefail
|
|
# Why: use npm version --no-git-tag-version so we control the commit
|
|
# message and tag name explicitly (avoids npm's `v1.2.3` prefix
|
|
# assumptions and any lifecycle scripts that would run on bump).
|
|
npm version "$VERSION" --no-git-tag-version --allow-same-version
|
|
# Why: the cut is the only point where committed skill bytes become a
|
|
# released revision. Without this row the ledger never advances, so the
|
|
# next skill change rebuilds the revision this tag ships over different
|
|
# bytes and every install of it stops matching a known snapshot.
|
|
# --release is provenance-only: it fails if the content-addressed
|
|
# artifacts do not already match this ref and writes just the mapping
|
|
# row, so the version commit stays skill-independent. Node built-ins
|
|
# only, so this needs no install.
|
|
if ! node config/scripts/generate-skill-bundle-manifest.mjs --release "$VERSION"; then
|
|
echo "::error::Refusing to record release provenance for v$VERSION: the committed skill artifacts do not match this ref. Land a regeneration on main, then re-run the cut." >&2
|
|
exit 1
|
|
fi
|
|
git add package.json resources/skills/release-mapping.json
|
|
commit_message="release: v$VERSION"
|
|
if [[ "$EVENT_NAME" == "schedule" ]]; then
|
|
commit_message="$commit_message [rc-slot:$SLOT]"
|
|
fi
|
|
if git diff --cached --quiet; then
|
|
# Why: a failed cut can push the version bump to main before the
|
|
# release is published. Re-cutting then needs a fresh taggable
|
|
# release commit even though package.json is already at VERSION.
|
|
git commit --allow-empty -m "$commit_message"
|
|
else
|
|
git commit -m "$commit_message"
|
|
fi
|
|
# Why: a lint that greps this file cannot see a path built from an env
|
|
# var, a composite action, or concatenation, and `git commit` has forms
|
|
# (-a, -i, --only, a pathspec) that commit the working tree rather than
|
|
# the index. Assert what the commit actually carries, so the tag can
|
|
# only ever ship the version bump and the provenance row, no matter
|
|
# which step staged what or how the commit was spelled.
|
|
# -F because the allowlist is literal: unanchored, `.` would match any
|
|
# character and quietly admit a path like `packageXjson`.
|
|
# -m --first-parent: plain diff-tree prints NOTHING for a merge commit,
|
|
# which would make this guard pass silently rather than fail closed.
|
|
committed="$(git diff-tree --no-commit-id --name-only -r -m --first-parent HEAD |
|
|
grep -vxF -e 'package.json' -e 'resources/skills/release-mapping.json' || true)"
|
|
if [[ -n "$committed" ]]; then
|
|
echo "::error::Release commit carries unexpected paths: $(echo "$committed" | tr '\n' ' ')Only package.json and the skill release-mapping row 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
|
|
# Fast-forward main to include the version-bump commit.
|
|
git push origin "HEAD:refs/heads/main"
|
|
git push origin "$TAG"
|
|
else
|
|
# Off-main release — only the tag is published; main is untouched.
|
|
git push origin "$TAG"
|
|
fi
|
|
|
|
- name: Release E2E signal summary
|
|
if: always()
|
|
run: |
|
|
{
|
|
echo "## Release E2E Signal"
|
|
echo ""
|
|
echo "- Terminal rendering golden is release-blocking."
|
|
echo "- Full E2E is diagnostic/non-blocking release evidence."
|
|
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: Create draft release with bounded generated notes
|
|
env:
|
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
TAG: ${{ needs.cut.outputs.tag }}
|
|
run: |
|
|
if gh release view "$TAG" --repo "$GITHUB_REPOSITORY" >/dev/null 2>&1; then
|
|
echo "Release $TAG already exists."
|
|
exit 0
|
|
fi
|
|
|
|
node config/scripts/create-draft-release.mjs "$TAG"
|
|
|
|
# Why: tag-scoped E2E gives release visibility, but the suite is flaky enough
|
|
# that publish-release must not depend on it.
|
|
e2e:
|
|
needs: cut
|
|
if: needs.cut.outputs.should_release == 'true'
|
|
uses: ./.github/workflows/e2e.yml
|
|
with:
|
|
ref: refs/tags/${{ needs.cut.outputs.tag }}
|
|
|
|
terminal-rendering-golden:
|
|
needs: cut
|
|
if: needs.cut.outputs.should_release == 'true'
|
|
name: terminal rendering golden ${{ 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
|
|
# Why: Windows terminal rendering golden is temporarily disabled on
|
|
# CI while its flaky runner-only failures are investigated.
|
|
# - 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 Node.js
|
|
uses: actions/setup-node@v6
|
|
with:
|
|
node-version-file: package.json
|
|
|
|
- name: Setup pnpm
|
|
uses: pnpm/action-setup@v6
|
|
with:
|
|
run_install: false
|
|
|
|
# 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"
|
|
|
|
- name: Install dependencies
|
|
run: pnpm install --frozen-lockfile
|
|
|
|
- name: Build Electron app for terminal rendering golden
|
|
run: npx electron-vite build --mode e2e
|
|
|
|
- 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 test:e2e:terminal-rendering-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 test:e2e:terminal-rendering-golden
|
|
|
|
- name: Upload Playwright traces
|
|
if: failure()
|
|
uses: actions/upload-artifact@v7
|
|
with:
|
|
name: terminal-rendering-golden-${{ matrix.platform }}-playwright-traces
|
|
path: test-results/
|
|
retention-days: 7
|
|
if-no-files-found: ignore
|
|
|
|
# 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 Node.js
|
|
uses: actions/setup-node@v6
|
|
with:
|
|
node-version-file: package.json
|
|
|
|
- name: Setup pnpm
|
|
uses: pnpm/action-setup@v6
|
|
with:
|
|
run_install: false
|
|
|
|
# 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"
|
|
|
|
- name: Install dependencies
|
|
run: 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:
|
|
needs:
|
|
- cut
|
|
- create-release
|
|
if: needs.cut.outputs.should_release == 'true'
|
|
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
|
|
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
|
|
eb_cache_path: |
|
|
~/.cache/electron
|
|
~/.cache/electron-builder
|
|
|
|
runs-on: ${{ matrix.os }}
|
|
# Why: hosted runners hard-cap jobs at 6h; the Windows SignPath waits
|
|
# (1h inner + 4h installer) are budgeted to fit under this with the
|
|
# build itself, so a slow approval can't kill the job mid-flow.
|
|
timeout-minutes: 360
|
|
|
|
permissions:
|
|
actions: read
|
|
contents: write
|
|
|
|
steps:
|
|
- name: Checkout
|
|
uses: actions/checkout@v6
|
|
with:
|
|
ref: refs/tags/${{ needs.cut.outputs.tag }}
|
|
|
|
# pnpm must be on PATH before setup-node so setup-node can locate the store for caching.
|
|
- name: Setup pnpm
|
|
uses: pnpm/action-setup@v6
|
|
with:
|
|
run_install: false
|
|
|
|
- name: Setup Node.js
|
|
uses: actions/setup-node@v6
|
|
with:
|
|
node-version-file: package.json
|
|
cache: pnpm
|
|
|
|
# Why: release builds hit the same native-module postinstall path as
|
|
# PR CI, so keep the pinned node-gyp override here too instead of
|
|
# relying on pnpm's bundled copy. Scoped to Linux via runner.os (not
|
|
# a specific matrix image) because the failing postinstall has only
|
|
# been observed on Linux runners — see run 25081763129. The macOS
|
|
# and Windows release jobs exercise the same pnpm install path and
|
|
# have not reproduced it, so keep the gate narrow until we know why.
|
|
# Using runner.os instead of matrix.os == 'ubuntu-latest' means the
|
|
# gate still works if another Linux matrix entry is added later.
|
|
- name: Use external node-gyp to avoid pnpm's bundled copy (Linux only)
|
|
if: runner.os == 'Linux'
|
|
run: |
|
|
npm install -g node-gyp@11.5.0
|
|
echo "npm_config_node_gyp=$(npm root -g)/node-gyp/bin/node-gyp.js" >> "$GITHUB_ENV"
|
|
|
|
# Cache the Electron binary + electron-builder tool downloads
|
|
# (winCodeSign, nsis, squirrel, AppImage). Saves ~30-90s per job.
|
|
- name: Cache electron-builder downloads
|
|
uses: actions/cache@v5
|
|
with:
|
|
path: ${{ matrix.eb_cache_path }}
|
|
key: electron-builder-${{ matrix.platform }}-${{ hashFiles('pnpm-lock.yaml') }}
|
|
restore-keys: |
|
|
electron-builder-${{ matrix.platform }}-
|
|
|
|
# Why: pnpm install triggers electron's postinstall, which downloads the
|
|
# Electron binary from GitHub release assets. GitHub's download CDN
|
|
# occasionally returns 504s that fail the whole release. Retry on
|
|
# failure so transient network errors don't require a manual re-run.
|
|
- name: Install dependencies
|
|
uses: nick-fields/retry@v4
|
|
with:
|
|
timeout_minutes: 10
|
|
max_attempts: 3
|
|
retry_wait_seconds: 30
|
|
command: pnpm install --frozen-lockfile
|
|
|
|
# Why: `pnpm build:release` verifies the Linux computer-use provider by
|
|
# importing AT-SPI bindings, which are runtime package deps but are not
|
|
# present on stock GitHub Ubuntu release runners.
|
|
# Why: `rpm` is needed by electron-builder's fpm backend to produce the
|
|
# .rpm artifact. Stock Ubuntu runners do not ship it.
|
|
- name: Install Linux computer-use provider dependencies
|
|
if: runner.os == 'Linux'
|
|
run: sudo apt-get update && sudo apt-get install -y python3-gi gir1.2-atspi-2.0 at-spi2-core xclip xdotool rpm
|
|
|
|
# Why: telemetry's transport gate (`src/main/telemetry/client.ts:IS_OFFICIAL_BUILD`)
|
|
# requires the build identity to be the literal string `stable` or `rc`,
|
|
# substituted by electron-vite's `define` block at build time. Derive
|
|
# that identity from the release tag here — `stable` for plain semver
|
|
# (`vX.Y.Z`), `rc` for prerelease (`vX.Y.Z-rc.N`). The strict regex is
|
|
# a safety net: this workflow only fires on cut-tags that already match
|
|
# one of those shapes, but if a future change ever loosens that, we
|
|
# refuse to ship rather than let an unclassified build go out with
|
|
# `BUILD_IDENTITY = null`.
|
|
- name: Classify release tag for telemetry build identity
|
|
id: tag-classify
|
|
shell: bash
|
|
env:
|
|
TAG: ${{ needs.cut.outputs.tag }}
|
|
run: |
|
|
set -euo pipefail
|
|
# Why the optional trailing identifier: suffixed side-branch RCs
|
|
# (vX.Y.Z-rc.N.perf) are rc-channel prerelease builds — same telemetry
|
|
# identity as plain RCs.
|
|
if [[ "$TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+-rc\.[0-9]+(\.[0-9A-Za-z]+)?$ ]]; then
|
|
identity=rc
|
|
elif [[ "$TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
|
|
identity=stable
|
|
else
|
|
echo "::error::Tag $TAG does not match stable or rc pattern; refusing to build official artifact"
|
|
exit 1
|
|
fi
|
|
echo "identity=$identity" >>"$GITHUB_OUTPUT"
|
|
echo "Classified $TAG as $identity"
|
|
|
|
# Why ORCA_POSTHOG_WRITE_KEY here: this is the only build that
|
|
# produces a published binary, so this is the only place the secret
|
|
# needs to be in scope. The key is a PostHog *project* API key, not
|
|
# a server secret — it ships in every official binary's app.asar
|
|
# and is therefore extractable from any release. We still keep it
|
|
# in GitHub Actions secrets so the literal stays out of the repo
|
|
# (and out of fork CI runs / log scrapers / casual greps).
|
|
# Why ORCA_BUILD_IDENTITY here (not in env at the job level): the
|
|
# value comes from the per-tag classification above and electron-vite
|
|
# reads it from `process.env` during `pnpm build:release` only.
|
|
# Why ORCA_DIAGNOSTICS_TOKEN_URL here: official builds pin crash
|
|
# diagnostic uploads to Orca's endpoint at compile time, matching the
|
|
# telemetry gate's "official binary only" behavior.
|
|
- name: Build app
|
|
run: pnpm build:release
|
|
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 }}
|
|
ORCA_DIAGNOSTICS_TOKEN_URL: https://www.onorca.dev/diagnostics/token
|
|
ORCA_POSTHOG_WRITE_KEY: ${{ secrets.ORCA_POSTHOG_WRITE_KEY }}
|
|
|
|
- name: Gate runtime file-watcher process isolation
|
|
if: runner.os == 'Linux'
|
|
run: |
|
|
# Why: #8212 is a native-process crash contract. Prove both the Node
|
|
# host and the exact Electron runtime survive SIGSEGV before packaging.
|
|
node config/scripts/runtime-file-watcher-fault-harness.mjs
|
|
ELECTRON_RUN_AS_NODE=1 pnpm exec electron config/scripts/runtime-file-watcher-fault-harness.mjs
|
|
|
|
- name: Gate SSH relay watcher process isolation
|
|
run: |
|
|
# Why: the remote native watcher shares a daemon with live PTYs.
|
|
# Kill only its child and require both PTY and watch recovery before packaging.
|
|
node config/scripts/relay-watcher-fault-harness.mjs
|
|
|
|
- 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 }}
|
|
|
|
# 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'
|
|
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: Verify Windows node-pty ConPTY runtime
|
|
if: matrix.platform == 'win'
|
|
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
|
|
if: matrix.platform == 'win'
|
|
shell: pwsh
|
|
run: |
|
|
$ErrorActionPreference = 'Stop'
|
|
# Why: force TLS 1.2 so gallery downloads work on older hosted images.
|
|
[Net.ServicePointManager]::SecurityProtocol = [Net.ServicePointManager]::SecurityProtocol -bor [Net.SecurityProtocolType]::Tls12
|
|
|
|
# Why: on some hosted Windows images `Register-PSRepository -Default`
|
|
# fails inside the legacy nuget.exe provider with "Missing option value
|
|
# for: '-source'", so PSGallery is never registered and the install
|
|
# below dies with "No repository with the name 'PSGallery'". PSResourceGet
|
|
# (bundled with PowerShell 7.4+) has PSGallery registered by default and
|
|
# avoids that code path, so prefer it and fall back to PowerShellGet only
|
|
# when it is absent.
|
|
$useResourceGet = $null -ne (Get-Command -Name Install-PSResource -ErrorAction SilentlyContinue)
|
|
|
|
if ($useResourceGet) {
|
|
if ($null -eq (Get-PSResourceRepository -Name PSGallery -ErrorAction SilentlyContinue)) {
|
|
Register-PSResourceRepository -PSGallery -Trusted
|
|
} else {
|
|
Set-PSResourceRepository -Name PSGallery -Trusted
|
|
}
|
|
} else {
|
|
Install-PackageProvider -Name NuGet -MinimumVersion 2.8.5.201 -Force | Out-Null
|
|
if ($null -eq (Get-PSRepository -Name PSGallery -ErrorAction SilentlyContinue)) {
|
|
Register-PSRepository -Default -InstallationPolicy Trusted
|
|
}
|
|
Set-PSRepository -Name PSGallery -InstallationPolicy Trusted
|
|
}
|
|
|
|
$trimChars = [char[]]@([System.IO.Path]::DirectorySeparatorChar, [System.IO.Path]::AltDirectorySeparatorChar)
|
|
$documentsRoot = [System.IO.Path]::GetFullPath([Environment]::GetFolderPath('MyDocuments')).TrimEnd($trimChars)
|
|
$currentUserModuleRoot = $env:PSModulePath -split [System.IO.Path]::PathSeparator |
|
|
Where-Object {
|
|
if ([string]::IsNullOrWhiteSpace($_)) {
|
|
$false
|
|
} else {
|
|
$candidate = [System.IO.Path]::GetFullPath($_).TrimEnd($trimChars)
|
|
$candidate.StartsWith($documentsRoot, [System.StringComparison]::OrdinalIgnoreCase)
|
|
}
|
|
} |
|
|
Select-Object -First 1
|
|
|
|
if ([string]::IsNullOrWhiteSpace($currentUserModuleRoot)) {
|
|
throw 'Unable to resolve the current-user PowerShell module root from PSModulePath.'
|
|
}
|
|
|
|
$signPathModulePath = Join-Path -Path $currentUserModuleRoot -ChildPath 'SignPath'
|
|
|
|
for ($attempt = 1; $attempt -le 3; $attempt++) {
|
|
if ($attempt -eq 2) {
|
|
Start-Sleep -Seconds 15
|
|
} elseif ($attempt -eq 3) {
|
|
Start-Sleep -Seconds 30
|
|
}
|
|
|
|
try {
|
|
if ($useResourceGet) {
|
|
Install-PSResource -Name SignPath -Version '[4.0.0,5.0.0)' -Repository PSGallery -Scope CurrentUser -TrustRepository -Reinstall -ErrorAction Stop
|
|
} else {
|
|
Install-Module -Name SignPath -Repository PSGallery -MinimumVersion 4.0.0 -MaximumVersion 4.999.999 -Scope CurrentUser -Force -AllowClobber -ErrorAction Stop
|
|
}
|
|
Import-Module SignPath -ErrorAction Stop
|
|
Get-Command -Name Get-SignedArtifact -Module SignPath -ErrorAction Stop
|
|
break
|
|
} catch {
|
|
if ($attempt -eq 3) {
|
|
throw
|
|
}
|
|
|
|
Write-Warning "SignPath PowerShell module preflight attempt $attempt failed: $_"
|
|
if (Test-Path -LiteralPath $signPathModulePath) {
|
|
Write-Warning "Removing current-user SignPath module directory before retry: $signPathModulePath"
|
|
Remove-Item -LiteralPath $signPathModulePath -Recurse -Force
|
|
}
|
|
}
|
|
}
|
|
|
|
# ── Windows inner-binary signing (issue #7785) ─────────────────────
|
|
# Why: SignPath cannot deep-sign inside NSIS installers, so inner PE
|
|
# files (Orca.exe, node-pty *.node, DLLs) are signed via a separate zip
|
|
# request, then the installer is rebuilt from the signed tree before the
|
|
# existing installer signing request below. Every step in this chain is
|
|
# fail-open (continue-on-error + outcome gating): any failure ships the
|
|
# original installer with unsigned inner binaries, exactly like releases
|
|
# did before this chain existed. Rehearsed end to end in run 28988432001
|
|
# (.github/workflows/windows-signing-rehearsal.yml).
|
|
|
|
# Why: only unsigned PE files go to SignPath. Files that already carry a
|
|
# valid signature (Microsoft's OpenConsole.exe) must keep their signer.
|
|
- name: Stage unsigned inner PE files for signing
|
|
id: stage-inner
|
|
if: matrix.platform == 'win'
|
|
continue-on-error: true
|
|
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)
|
|
$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 " $_" }
|
|
|
|
- name: Upload unsigned inner binaries for SignPath
|
|
id: upload-unsigned-inner
|
|
if: matrix.platform == 'win' && steps.stage-inner.outcome == 'success'
|
|
continue-on-error: true
|
|
uses: actions/upload-artifact@v7
|
|
with:
|
|
name: orca-windows-inner-unsigned-${{ needs.cut.outputs.tag }}
|
|
path: signing-stage/**
|
|
if-no-files-found: error
|
|
|
|
- name: Submit inner binaries signing request
|
|
id: submit-inner-signing
|
|
if: matrix.platform == 'win' && steps.upload-unsigned-inner.outcome == 'success'
|
|
continue-on-error: true
|
|
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
|
|
if: matrix.platform == 'win' && steps.submit-inner-signing.outcome == 'success'
|
|
continue-on-error: true
|
|
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 }}
|
|
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"
|
|
}
|
|
|
|
$message = "Orca Windows release $env:TAG inner-binaries signing request (1 of 2) is ready for SignPath approval.`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
|
|
|
|
# Why gate on the notify outcome too: if nobody was told to approve,
|
|
# don't hold the release for the approval window — fall through and
|
|
# ship like today instead. The 1h wait (vs the installer's 4h) keeps
|
|
# both waits plus the build inside the 360-minute job cap; missing it
|
|
# falls through to today's unsigned-inner flow rather than blocking.
|
|
- name: Download signed inner binaries from SignPath
|
|
id: download-signed-inner
|
|
if: matrix.platform == 'win' && steps.submit-inner-signing.outcome == 'success' && steps.notify-inner-signing.outcome == 'success'
|
|
continue-on-error: true
|
|
shell: pwsh
|
|
env:
|
|
SIGNPATH_API_TOKEN: ${{ secrets.SIGNPATH_API_TOKEN }}
|
|
SIGNPATH_REQUEST_ID: ${{ steps.submit-inner-signing.outputs.signing-request-id }}
|
|
run: |
|
|
Get-SignedArtifact `
|
|
-OrganizationId c37aa192-a27a-4377-9c90-5d6c95912dc0 `
|
|
-ApiToken $env:SIGNPATH_API_TOKEN `
|
|
-SigningRequestId $env:SIGNPATH_REQUEST_ID `
|
|
-OutputArtifactPath signed-inner.zip `
|
|
-Force `
|
|
-WaitForCompletionTimeoutInSeconds 3600
|
|
|
|
New-Item -ItemType Directory -Path signed-inner -Force
|
|
Expand-Archive -Path signed-inner.zip -DestinationPath signed-inner -Force
|
|
|
|
# Why: copy back strictly by the staged list so a layout mismatch in the
|
|
# returned artifact fails loudly (into fail-open) instead of silently
|
|
# shipping a mix of signed and unsigned binaries.
|
|
- name: Restore signed inner binaries into unpacked app
|
|
id: restore-signed-inner
|
|
if: matrix.platform == 'win' && steps.download-signed-inner.outcome == 'success'
|
|
continue-on-error: true
|
|
shell: pwsh
|
|
run: |
|
|
$root = Resolve-Path 'dist/win-unpacked'
|
|
$failures = New-Object System.Collections.Generic.List[string]
|
|
foreach ($relative in Get-Content 'inner-signing-list.txt') {
|
|
$signed = Get-ChildItem -Path signed-inner -Recurse -File |
|
|
Where-Object { [System.IO.Path]::GetRelativePath((Resolve-Path 'signed-inner'), $_.FullName).TrimStart('\', '/') -like "*$relative" } |
|
|
Select-Object -First 1
|
|
if ($null -eq $signed) {
|
|
$failures.Add("missing from signed artifact: $relative")
|
|
continue
|
|
}
|
|
$signature = Get-AuthenticodeSignature -FilePath $signed.FullName
|
|
if ($null -eq $signature.SignerCertificate) {
|
|
$failures.Add("returned without a signature: $relative")
|
|
continue
|
|
}
|
|
Copy-Item -Path $signed.FullName -Destination (Join-Path $root $relative) -Force
|
|
Write-Host ("{0,-14} {1} <{2}>" -f $signature.Status, $relative, $signature.SignerCertificate.Subject)
|
|
}
|
|
if ($failures.Count -gt 0) {
|
|
$failures | ForEach-Object { Write-Host "::error::$_" }
|
|
throw "Signed inner artifact did not round-trip cleanly ($($failures.Count) failures)."
|
|
}
|
|
|
|
# Why this step exists: electron-builder's CopyElevateHelper re-copies a
|
|
# pristine elevate.exe from its download cache over resources\elevate.exe
|
|
# on EVERY nsis pack — including the --prepackaged rebuild below — which
|
|
# clobbered the SignPath signature in v1.4.129-rc.4. There is no supported
|
|
# way to disable just the copy, so we overwrite the cache's copy with our
|
|
# signed one (identical bytes plus signature) so the clobber becomes a
|
|
# no-op. Known quirk: the cache persists across releases via actions/cache,
|
|
# so later runs may see elevate.exe as already signed and skip staging it —
|
|
# that is fine (the signature is timestamped) and the evidence gate checks
|
|
# elevate.exe in the shipped installer unconditionally. If this ever causes
|
|
# trouble, delete this step; the only effect is elevate.exe shipping
|
|
# unsigned again, which the evidence gate will flag.
|
|
- name: Replace cached elevate.exe with the signed copy
|
|
id: sign-elevate-cache
|
|
if: matrix.platform == 'win' && steps.restore-signed-inner.outcome == 'success'
|
|
continue-on-error: true
|
|
shell: pwsh
|
|
run: |
|
|
$signed = 'dist/win-unpacked/resources/elevate.exe'
|
|
if (-not (Test-Path $signed)) {
|
|
Write-Host '::warning::No elevate.exe in win-unpacked resources; nothing to protect from the rebuild clobber.'
|
|
exit 0
|
|
}
|
|
$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*') {
|
|
Write-Host "::warning::win-unpacked elevate.exe is not SignPath-signed ($($signature.Status), $subject); skipping cache swap."
|
|
exit 0
|
|
}
|
|
$cached = @(Get-ChildItem "$env:LOCALAPPDATA\electron-builder\Cache\nsis" -Recurse -Filter elevate.exe -ErrorAction SilentlyContinue)
|
|
if ($cached.Count -eq 0) {
|
|
Write-Host '::warning::No cached elevate.exe found (electron-builder cache layout changed?); the rebuild will pack the unsigned copy and the evidence gate will flag it.'
|
|
exit 0
|
|
}
|
|
foreach ($file in $cached) {
|
|
Copy-Item -Path $signed -Destination $file.FullName -Force
|
|
Write-Host "Replaced $($file.FullName) with the SignPath-signed copy."
|
|
}
|
|
|
|
- name: Rebuild NSIS installer from signed unpacked app
|
|
id: rebuild-nsis-signed
|
|
if: matrix.platform == 'win' && steps.restore-signed-inner.outcome == 'success'
|
|
continue-on-error: true
|
|
shell: pwsh
|
|
run: |
|
|
# Why: keep the pre-rebuild artifacts so a failed rebuild can fall
|
|
# back to shipping them unchanged (fail-open).
|
|
New-Item -ItemType Directory -Path prepack-backup -Force | Out-Null
|
|
Copy-Item 'dist/orca-windows-setup.exe' 'prepack-backup/orca-windows-setup.exe' -Force
|
|
Copy-Item 'dist/latest.yml' 'prepack-backup/latest.yml' -Force
|
|
|
|
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'
|
|
}
|
|
|
|
- name: Roll back to original installer after failed rebuild
|
|
if: matrix.platform == 'win' && steps.rebuild-nsis-signed.outcome == 'failure'
|
|
shell: pwsh
|
|
run: |
|
|
if (Test-Path 'prepack-backup/orca-windows-setup.exe') {
|
|
Copy-Item 'prepack-backup/orca-windows-setup.exe' 'dist/orca-windows-setup.exe' -Force
|
|
Copy-Item 'prepack-backup/latest.yml' 'dist/latest.yml' -Force
|
|
Write-Warning 'Restored pre-rebuild installer; this release ships with unsigned inner binaries.'
|
|
}
|
|
# ── End Windows inner-binary signing ───────────────────────────────
|
|
|
|
- name: Upload unsigned Windows installer for SignPath
|
|
if: matrix.platform == 'win'
|
|
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
|
|
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'
|
|
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'
|
|
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 }}
|
|
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"
|
|
}
|
|
|
|
# Why: releases where inner signing fell through have only this one request.
|
|
$stage = if ($env:INNER_SIGNING_SUBMITTED -eq 'true') { 'installer signing request (2 of 2)' } else { 'signing request' }
|
|
$message = "Orca Windows release $env:TAG $stage is ready for SignPath approval.`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'
|
|
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'
|
|
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'
|
|
shell: pwsh
|
|
run: |
|
|
$signature = Get-AuthenticodeSignature -FilePath 'dist/orca-windows-setup.exe'
|
|
if ($signature.Status -ne 'Valid') {
|
|
throw ($signature | Format-List * | Out-String)
|
|
}
|
|
if ($signature.SignerCertificate.Subject -notlike '*CN=SignPath Foundation*') {
|
|
throw "Unexpected Windows signer: $($signature.SignerCertificate.Subject)"
|
|
}
|
|
$signature.SignerCertificate | Format-List Subject,Issuer,NotBefore,NotAfter,Thumbprint
|
|
|
|
# Why: evidence gate for inner-binary signing (issue #7785, supersedes
|
|
# PR #7170's Orca.exe-only gate — this covers every staged .exe/.dll/.node
|
|
# by extracting the shipped installer). Warn-only until the flow has been
|
|
# proven on a real release, then flip ORCA_WINDOWS_INNER_SIGNATURE_REQUIRED
|
|
# to 'true' so unsigned inner binaries block the release.
|
|
- name: Verify Windows inner binary signatures
|
|
if: matrix.platform == 'win'
|
|
shell: pwsh
|
|
env:
|
|
ORCA_WINDOWS_INNER_SIGNATURE_REQUIRED: 'false'
|
|
INNER_SIGNING_COMPLETED: ${{ steps.rebuild-nsis-signed.outcome == 'success' }}
|
|
run: |
|
|
$required = $env:ORCA_WINDOWS_INNER_SIGNATURE_REQUIRED -eq 'true'
|
|
if ($env:INNER_SIGNING_COMPLETED -ne 'true') {
|
|
$message = 'Windows inner-binary signing did not complete; this release ships unsigned inner binaries (fail-open, issue #7785).'
|
|
if ($required) { throw $message }
|
|
Write-Host "::warning::$message"
|
|
exit 0
|
|
}
|
|
|
|
# Why try/catch: while the gate is warn-only, even an unexpected
|
|
# script error (extraction hiccup, missing file) must not block
|
|
# the release — only the flip to required makes failures fatal.
|
|
try {
|
|
$report = New-Object System.Collections.Generic.List[string]
|
|
$failures = New-Object System.Collections.Generic.List[string]
|
|
|
|
# Why: verify the files a user actually gets on disk, not the build
|
|
# tree — 7z parses the NSIS exe directly as its embedded payload.
|
|
$7za = 'node_modules/7zip-bin/win/x64/7za.exe'
|
|
New-Item -ItemType Directory -Path inner-evidence-extract -Force | Out-Null
|
|
& $7za x 'dist/orca-windows-setup.exe' '-oinner-evidence-extract' -y | Out-Null
|
|
|
|
$root = Resolve-Path 'inner-evidence-extract'
|
|
# Why elevate.exe is always appended: staging skips already-signed
|
|
# files, and the persisted electron-builder cache can carry a
|
|
# previously signed elevate.exe — so it may be absent from the list
|
|
# in some runs, yet it is the file most at risk of losing its
|
|
# signature in the NSIS rebuild. Verify it in every release.
|
|
$targets = @(Get-Content 'inner-signing-list.txt')
|
|
if ($targets -notcontains 'resources\elevate.exe') {
|
|
$targets += 'resources\elevate.exe'
|
|
}
|
|
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."
|
|
if ($required) { throw $message }
|
|
Write-Host "::warning::$message Fail-open until ORCA_WINDOWS_INNER_SIGNATURE_REQUIRED is 'true'."
|
|
} else {
|
|
Write-Host "All $($targets.Count) inner binaries in the shipped installer are signed by SignPath Foundation."
|
|
}
|
|
} catch {
|
|
if ($required) { throw }
|
|
Write-Host "::warning::Windows inner-binary evidence gate errored: $_ (fail-open, issue #7785)."
|
|
}
|
|
|
|
- name: Upload Windows inner signing evidence
|
|
if: always() && matrix.platform == 'win'
|
|
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'
|
|
uses: nick-fields/retry@v4
|
|
with:
|
|
timeout_minutes: 10
|
|
max_attempts: 3
|
|
retry_wait_seconds: 30
|
|
command: gh release upload "${{ needs.cut.outputs.tag }}" "dist/orca-windows-setup.exe" "dist/orca-windows-setup.exe.blockmap" "dist/latest.yml" --clobber --repo "${{ github.repository }}"
|
|
env:
|
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
|
|
- name: Verify release remains draft after artifact upload
|
|
# Why: the build matrix must never be the actor that exposes a partial
|
|
# release. If an uploader or GitHub transition flips draft early, fail
|
|
# this platform leg and leave the diagnostic monitor artifact behind.
|
|
shell: bash
|
|
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: release upload must validate the draft before it is publicly 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 during the ${{ matrix.platform }} artifact upload."
|
|
exit 1
|
|
fi
|
|
|
|
# Why post-publish for Linux: electron-builder packs and uploads in a
|
|
# single `--publish always` invocation, so there is no cheap insertion
|
|
# point between pack and upload without splitting those steps. Running
|
|
# verify last still blocks the bad release: the binary is uploaded to the
|
|
# draft, but a failed matrix job blocks `publish-release` from flipping
|
|
# the release from draft → published, so users never see it. A human then
|
|
# deletes the draft and re-cuts.
|
|
#
|
|
# Why this guards against: a misconfigured CI run where
|
|
# `ORCA_POSTHOG_WRITE_KEY` is unset or the tag fails to classify
|
|
# would otherwise produce a binary with `BUILD_IDENTITY = null` and
|
|
# `WRITE_KEY = null`, which silently disables transport
|
|
# (`IS_OFFICIAL_BUILD === false`) — the exact failure mode flagged
|
|
# in PR #1385's deferred follow-up.
|
|
- name: Verify telemetry constants present in app.asar
|
|
run: node config/scripts/verify-telemetry-constants.mjs
|
|
|
|
build-mac:
|
|
needs:
|
|
- cut
|
|
- create-release
|
|
if: needs.cut.outputs.should_release == 'true'
|
|
# Why: SignPath requires every job in this signing workflow to be
|
|
# GitHub-hosted. The actual mac build runs in release-mac-build.yml so
|
|
# Blacksmith stays outside Windows artifact provenance.
|
|
runs-on: ubuntu-latest
|
|
permissions:
|
|
actions: write
|
|
contents: read
|
|
steps:
|
|
- name: Checkout
|
|
uses: actions/checkout@v6
|
|
|
|
- name: Setup Node.js
|
|
uses: actions/setup-node@v6
|
|
with:
|
|
node-version-file: package.json
|
|
|
|
- name: Run isolated macOS release build
|
|
run: node config/scripts/run-release-mac-build-workflow.mjs
|
|
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_TAG: ${{ needs.cut.outputs.tag }}
|
|
RELEASE_MAC_BUILD_WORKFLOW: release-mac-build.yml
|
|
|
|
publish-release:
|
|
needs:
|
|
- cut
|
|
- build
|
|
- build-mac
|
|
- terminal-rendering-golden
|
|
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
|
|
|
|
- name: Verify release is still draft
|
|
env:
|
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
TAG: ${{ needs.cut.outputs.tag }}
|
|
run: |
|
|
set -euo pipefail
|
|
releases_json="$(gh api "repos/$GITHUB_REPOSITORY/releases?per_page=100")"
|
|
# Why: publish-release verifies the draft before making it visible.
|
|
draft="$(jq -e -r --arg tag "$TAG" '
|
|
map(select(.tag_name == $tag))
|
|
| if length == 1 and (.[0].draft | type) == "boolean" then (.[0].draft | tostring) else empty end
|
|
' <<<"$releases_json")" || {
|
|
echo "::error::Release $TAG was not found in the draft-aware releases list, or its draft state was missing."
|
|
exit 1
|
|
}
|
|
if [[ "$draft" != "true" ]]; then
|
|
echo "::error::Release $TAG was published before publish-release; refusing to continue."
|
|
exit 1
|
|
fi
|
|
|
|
- name: Verify release assets complete
|
|
# Why: publish-release is the only intended draft -> published
|
|
# transition. Refuse to un-draft until every updater manifest and
|
|
# referenced installer asset is present on GitHub.
|
|
env:
|
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
TAG: ${{ needs.cut.outputs.tag }}
|
|
run: node config/scripts/verify-release-required-assets.mjs "$TAG"
|
|
|
|
- name: Publish release
|
|
# Why: derive `--prerelease` from the tag shape (not from whatever
|
|
# electron-builder left the release flagged as). On 2026-04-27,
|
|
# electron-builder's publish step flipped `prerelease` back to
|
|
# `false` on -rc.N releases, which caused an RC to be marked as
|
|
# GitHub's "latest" release and broke release-cut.yml's math.
|
|
# Re-asserting here means the final release state is determined
|
|
# by the tag — a ground truth electron-builder can't rewrite.
|
|
env:
|
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
TAG: ${{ needs.cut.outputs.tag }}
|
|
run: |
|
|
set -euo pipefail
|
|
if [[ "$TAG" == *"-rc."* ]]; then
|
|
prerelease=true
|
|
else
|
|
prerelease=false
|
|
fi
|
|
gh release edit "$TAG" \
|
|
--draft=false \
|
|
--prerelease="$prerelease" \
|
|
--repo "$GITHUB_REPOSITORY"
|
|
|
|
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
|
|
if: ${{ needs.cut.outputs.tag != '' && startsWith(needs.cut.outputs.tag, 'v') }}
|
|
uses: ./.github/workflows/homebrew-bump.yml
|
|
with:
|
|
tag: ${{ needs.cut.outputs.tag }}
|
|
secrets: inherit
|