fix(ci): stabilize runtime release checks

This commit is contained in:
777genius 2026-05-22 00:55:29 +03:00
parent c45e860d69
commit ad984a859b
5 changed files with 124 additions and 50 deletions

View file

@ -1,9 +1,6 @@
name: Release
on:
push:
tags:
- 'v*'
workflow_dispatch:
inputs:
release_tag:
@ -20,13 +17,13 @@ permissions:
contents: write
concurrency:
group: release-${{ startsWith(github.ref, 'refs/tags/v') && github.ref_name || inputs.release_tag || github.ref }}
group: release-${{ inputs.release_tag || github.ref }}
cancel-in-progress: false
env:
IS_RELEASE_BUILD: ${{ startsWith(github.ref, 'refs/tags/v') || github.event_name == 'workflow_dispatch' }}
RELEASE_TAG: ${{ startsWith(github.ref, 'refs/tags/v') && github.ref_name || inputs.release_tag }}
PUBLISH_RELEASE: ${{ startsWith(github.ref, 'refs/tags/v') || inputs.publish_release }}
IS_RELEASE_BUILD: ${{ github.event_name == 'workflow_dispatch' }}
RELEASE_TAG: ${{ inputs.release_tag }}
PUBLISH_RELEASE: ${{ inputs.publish_release }}
jobs:
build:
@ -81,8 +78,8 @@ jobs:
- name: Build app
env:
NODE_OPTIONS: '--max-old-space-size=8192'
SENTRY_DSN: ${{ (startsWith(github.ref, 'refs/tags/v') || github.event_name == 'workflow_dispatch') && secrets.SENTRY_DSN || '' }}
SENTRY_AUTH_TOKEN: ${{ (startsWith(github.ref, 'refs/tags/v') || github.event_name == 'workflow_dispatch') && secrets.SENTRY_AUTH_TOKEN || '' }}
SENTRY_DSN: ${{ github.event_name == 'workflow_dispatch' && secrets.SENTRY_DSN || '' }}
SENTRY_AUTH_TOKEN: ${{ github.event_name == 'workflow_dispatch' && secrets.SENTRY_AUTH_TOKEN || '' }}
SENTRY_ORG: ${{ secrets.SENTRY_ORG }}
SENTRY_PROJECT: ${{ secrets.SENTRY_PROJECT }}
run: pnpm build
@ -102,8 +99,7 @@ jobs:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
set -euo pipefail
TAG="${GITHUB_REF#refs/tags/}"
TAG="${RELEASE_TAG:-$TAG}"
TAG="${RELEASE_TAG}"
existing_is_draft="$(gh release view "$TAG" --repo "$GITHUB_REPOSITORY" --json isDraft --jq '.isDraft' 2>/dev/null || true)"
if [ -n "$existing_is_draft" ]; then
if [ "${PUBLISH_RELEASE}" != "true" ] && [ "$existing_is_draft" != "true" ]; then
@ -155,8 +151,7 @@ jobs:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
set -euo pipefail
TAG="${GITHUB_REF#refs/tags/}"
TAG="${RELEASE_TAG:-$TAG}"
TAG="${RELEASE_TAG}"
existing_is_draft="$(gh release view "$TAG" --repo "$GITHUB_REPOSITORY" --json isDraft --jq '.isDraft' 2>/dev/null || true)"
if [ -n "$existing_is_draft" ]; then
if [ "${PUBLISH_RELEASE}" != "true" ] && [ "$existing_is_draft" != "true" ]; then
@ -184,8 +179,7 @@ jobs:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
set -euo pipefail
TAG="${GITHUB_REF#refs/tags/}"
TAG="${RELEASE_TAG:-$TAG}"
TAG="${RELEASE_TAG}"
existing="$(gh release view "$TAG" --repo "$GITHUB_REPOSITORY" --json assets --jq '.assets[].name' 2>/dev/null || true)"
missing=0
while IFS= read -r asset; do
@ -278,8 +272,7 @@ jobs:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
set -euo pipefail
TAG="${GITHUB_REF#refs/tags/}"
TAG="${RELEASE_TAG:-$TAG}"
TAG="${RELEASE_TAG}"
for attempt in $(seq 1 12); do
existing="$(gh release view "$TAG" --repo "$GITHUB_REPOSITORY" --json assets --jq '.assets[].name' 2>/dev/null || true)"
all_found=1
@ -377,8 +370,8 @@ jobs:
- name: Build app (macOS ${{ matrix.arch }})
env:
NODE_OPTIONS: '--max-old-space-size=8192'
SENTRY_DSN: ${{ (startsWith(github.ref, 'refs/tags/v') || github.event_name == 'workflow_dispatch') && secrets.SENTRY_DSN || '' }}
SENTRY_AUTH_TOKEN: ${{ (startsWith(github.ref, 'refs/tags/v') || github.event_name == 'workflow_dispatch') && secrets.SENTRY_AUTH_TOKEN || '' }}
SENTRY_DSN: ${{ github.event_name == 'workflow_dispatch' && secrets.SENTRY_DSN || '' }}
SENTRY_AUTH_TOKEN: ${{ github.event_name == 'workflow_dispatch' && secrets.SENTRY_AUTH_TOKEN || '' }}
SENTRY_ORG: ${{ secrets.SENTRY_ORG }}
SENTRY_PROJECT: ${{ secrets.SENTRY_PROJECT }}
run: pnpm build
@ -494,8 +487,8 @@ jobs:
- name: Build app (Windows)
env:
NODE_OPTIONS: '--max-old-space-size=8192'
SENTRY_DSN: ${{ (startsWith(github.ref, 'refs/tags/v') || github.event_name == 'workflow_dispatch') && secrets.SENTRY_DSN || '' }}
SENTRY_AUTH_TOKEN: ${{ (startsWith(github.ref, 'refs/tags/v') || github.event_name == 'workflow_dispatch') && secrets.SENTRY_AUTH_TOKEN || '' }}
SENTRY_DSN: ${{ github.event_name == 'workflow_dispatch' && secrets.SENTRY_DSN || '' }}
SENTRY_AUTH_TOKEN: ${{ github.event_name == 'workflow_dispatch' && secrets.SENTRY_AUTH_TOKEN || '' }}
SENTRY_ORG: ${{ secrets.SENTRY_ORG }}
SENTRY_PROJECT: ${{ secrets.SENTRY_PROJECT }}
run: pnpm build
@ -614,8 +607,8 @@ jobs:
- name: Build app (Linux)
env:
NODE_OPTIONS: '--max-old-space-size=8192'
SENTRY_DSN: ${{ (startsWith(github.ref, 'refs/tags/v') || github.event_name == 'workflow_dispatch') && secrets.SENTRY_DSN || '' }}
SENTRY_AUTH_TOKEN: ${{ (startsWith(github.ref, 'refs/tags/v') || github.event_name == 'workflow_dispatch') && secrets.SENTRY_AUTH_TOKEN || '' }}
SENTRY_DSN: ${{ github.event_name == 'workflow_dispatch' && secrets.SENTRY_DSN || '' }}
SENTRY_AUTH_TOKEN: ${{ github.event_name == 'workflow_dispatch' && secrets.SENTRY_AUTH_TOKEN || '' }}
SENTRY_ORG: ${{ secrets.SENTRY_ORG }}
SENTRY_PROJECT: ${{ secrets.SENTRY_PROJECT }}
run: pnpm build
@ -670,7 +663,7 @@ jobs:
needs: [release-mac, release-win, release-linux]
runs-on: ubuntu-latest
timeout-minutes: 30
if: ${{ startsWith(github.ref, 'refs/tags/v') || github.event_name == 'workflow_dispatch' }}
if: ${{ github.event_name == 'workflow_dispatch' }}
steps:
- name: Upload stable download aliases
@ -794,7 +787,7 @@ jobs:
gh release upload "${TAG}" latest.yml latest-linux.yml latest-mac.yml --repo "${REPO}" --clobber
- name: Publish release
if: ${{ startsWith(github.ref, 'refs/tags/v') || inputs.publish_release }}
if: ${{ inputs.publish_release }}
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |

View file

@ -352,7 +352,11 @@ export function extractCodexRuntimePackageFilesFromTarball(
): CodexRuntimePackageFile[] {
const tar = gunzipSync(tarball, { maxOutputLength: MAX_UNPACKED_BYTES });
const targetPrefix = `package/vendor/${vendorTarget}/`;
const targetBinaryName = `${targetPrefix}codex/${executableName}`;
const targetBinaryNames = new Set(
getCodexRuntimeBinaryRelativePathCandidates(executableName).map(
(relativePath) => `${targetPrefix}${relativePath}`
)
);
const files: CodexRuntimePackageFile[] = [];
let foundBinary = false;
let offset = 0;
@ -383,7 +387,7 @@ export function extractCodexRuntimePackageFilesFromTarball(
data: Buffer.from(tar.subarray(dataStart, dataEnd)),
mode,
});
foundBinary = foundBinary || fullName === targetBinaryName;
foundBinary = foundBinary || targetBinaryNames.has(fullName);
}
} else if (
fullName.startsWith(targetPrefix) &&
@ -398,11 +402,31 @@ export function extractCodexRuntimePackageFilesFromTarball(
}
if (!foundBinary) {
throw new Error(`Codex package did not contain ${targetBinaryName}`);
throw new Error(
`Codex package did not contain one of ${Array.from(targetBinaryNames).join(', ')}`
);
}
return files;
}
function getCodexRuntimeBinaryRelativePathCandidates(executableName: string): string[] {
return [`bin/${executableName}`, `codex/${executableName}`];
}
function resolveCodexRuntimeBinaryRelativePath(
files: readonly CodexRuntimePackageFile[],
executableName = getExecutableName()
): string {
const filePaths = new Set(files.map((file) => file.relativePath));
const binaryPath = getCodexRuntimeBinaryRelativePathCandidates(executableName).find((candidate) =>
filePaths.has(candidate)
);
if (!binaryPath) {
throw new Error(`Extracted Codex package is missing ${executableName}`);
}
return binaryPath;
}
async function readCurrentManifest(): Promise<CodexRuntimeManifest | null> {
try {
const raw = await fsp.readFile(getCurrentManifestPath(), 'utf8');
@ -592,6 +616,7 @@ export class CodexRuntimeInstallerService implements CodexRuntimeInstallerPort {
this.publishProgress({ phase: 'installing', detail: 'Extracting Codex runtime...' });
const files = extractCodexRuntimePackageFilesFromTarball(tarball, selected.vendorTarget);
const binaryRelativePath = resolveCodexRuntimeBinaryRelativePath(files);
const runtimeRoot = getRuntimeRootPath();
tempDir = path.join(runtimeRoot, `installing-${process.pid}-${randomUUID()}`);
const versionDir = path.join(
@ -600,14 +625,14 @@ export class CodexRuntimeInstallerService implements CodexRuntimeInstallerPort {
platformMetadata.version!,
selected.vendorTarget
);
const binaryPath = path.join(versionDir, 'codex', getExecutableName());
const binaryPath = path.join(versionDir, binaryRelativePath);
await fsp.rm(tempDir, { recursive: true, force: true });
await fsp.mkdir(tempDir, { recursive: true });
await writePackageFiles(tempDir, files);
this.publishProgress({ phase: 'installing', detail: 'Verifying Codex binary...' });
const tempBinaryPath = path.join(tempDir, 'codex', getExecutableName());
const tempBinaryPath = path.join(tempDir, binaryRelativePath);
const { stdout } = await execCli(tempBinaryPath, ['--version'], {
timeout: VERSION_TIMEOUT_MS,
windowsHide: true,

View file

@ -233,7 +233,6 @@ async function probeFirstWorkingPathOpenCodeBinary(
const shellEnv = await resolveInteractiveShellEnvBestEffort({
timeoutMs: options.shellEnvTimeoutMs ?? RUNTIME_PATH_SHELL_ENV_TIMEOUT_MS,
fallbackEnv: process.env,
background: false,
});
const shellProbe = await probeFirstWorkingOpenCodeBinaryCandidate(
collectPathOpenCodeBinaryCandidates([shellEnv], {

View file

@ -220,6 +220,26 @@ function getNodeRuntimeCommandCandidates(): string[] {
});
}
function shouldPreferShellNodeProbe(): boolean {
if (process.platform === 'win32') {
return false;
}
const pathValue = process.env.PATH?.trim();
if (!pathValue) {
return true;
}
const minimalGuiPathEntries = new Set(['/usr/bin', '/bin', '/usr/sbin', '/sbin']);
const entries = pathValue
.split(path.delimiter)
.map((entry) => entry.trim())
.filter(Boolean);
return (
entries.length > 0 && entries.every((entry) => minimalGuiPathEntries.has(path.resolve(entry)))
);
}
function mergePathValues(...values: (string | undefined)[]): string | undefined {
const seen = new Set<string>();
const merged: string[] = [];
@ -281,23 +301,9 @@ async function probeNodeRuntimePath(
return { ok: false, error: lastError ?? 'no Node.js candidates were available' };
}
/**
* Find the real `node` binary path. In Electron, process.execPath is the
* Electron binary NOT node so we must resolve node separately.
* Uses the user's shell/enriched PATH so packaged GUI launches do not depend
* on the minimal Finder/Dock PATH.
*/
async function resolveNodePath(options?: McpLaunchSpecResolveOptions): Promise<string> {
if (_resolvedNodePath) return _resolvedNodePath;
emitProgress(options, 'node-runtime', 'Resolving Node.js runtime for MCP server...');
const fastProbe = await probeNodeRuntimePath(buildNodeResolveEnv({}));
if (fastProbe.ok) {
_resolvedNodePath = fastProbe.path;
emitProgress(options, 'node-runtime-found', 'Using resolved Node.js runtime...');
return _resolvedNodePath;
}
async function probeShellNodeRuntimePath(
options?: McpLaunchSpecResolveOptions
): Promise<{ ok: true; path: string } | { ok: false; error: unknown }> {
let shellEnv: NodeJS.ProcessEnv = {};
try {
shellEnv = await resolveInteractiveShellEnv({
@ -310,8 +316,36 @@ async function resolveNodePath(options?: McpLaunchSpecResolveOptions): Promise<s
logger.warn(`Failed to resolve shell env before Node.js lookup: ${stringifyError(error)}`);
}
const env = buildNodeResolveEnv(shellEnv);
const shellProbe = await probeNodeRuntimePath(env);
return probeNodeRuntimePath(buildNodeResolveEnv(shellEnv));
}
/**
* Find the real `node` binary path. In Electron, process.execPath is the
* Electron binary NOT node so we must resolve node separately.
* Uses the user's shell/enriched PATH so packaged GUI launches do not depend
* on the minimal Finder/Dock PATH.
*/
async function resolveNodePath(options?: McpLaunchSpecResolveOptions): Promise<string> {
if (_resolvedNodePath) return _resolvedNodePath;
emitProgress(options, 'node-runtime', 'Resolving Node.js runtime for MCP server...');
if (shouldPreferShellNodeProbe()) {
const shellProbe = await probeShellNodeRuntimePath(options);
if (shellProbe.ok) {
_resolvedNodePath = shellProbe.path;
emitProgress(options, 'node-runtime-found', 'Using resolved Node.js runtime...');
return _resolvedNodePath;
}
}
const fastProbe = await probeNodeRuntimePath(buildNodeResolveEnv({}));
if (fastProbe.ok) {
_resolvedNodePath = fastProbe.path;
emitProgress(options, 'node-runtime-found', 'Using resolved Node.js runtime...');
return _resolvedNodePath;
}
const shellProbe = await probeShellNodeRuntimePath(options);
if (shellProbe.ok) {
_resolvedNodePath = shellProbe.path;
emitProgress(options, 'node-runtime-found', 'Using resolved Node.js runtime...');

View file

@ -278,6 +278,29 @@ describe('CodexRuntimeInstallerService package safety helpers', () => {
);
});
it('extracts the current Codex platform package layout', () => {
const tarball = createTarball([
{ name: 'package/vendor/x86_64-unknown-linux-musl/bin/codex', data: 'codex-binary' },
{ name: 'package/vendor/x86_64-unknown-linux-musl/codex-path/rg', data: 'rg-binary' },
{ name: 'package/vendor/x86_64-unknown-linux-musl/codex-resources/bwrap', data: 'bwrap' },
]);
const files = extractCodexRuntimePackageFilesFromTarball(
tarball,
'x86_64-unknown-linux-musl',
'codex'
);
expect(files.map((file) => file.relativePath).sort((a, b) => a.localeCompare(b))).toEqual([
'bin/codex',
'codex-path/rg',
'codex-resources/bwrap',
]);
expect(files.find((file) => file.relativePath === 'bin/codex')?.data.toString()).toBe(
'codex-binary'
);
});
it('rejects tar path traversal before extraction', () => {
const tarball = createTarball([
{ name: '../codex', data: 'unsafe' },