fix(ci): stabilize runtime release checks
This commit is contained in:
parent
c45e860d69
commit
ad984a859b
5 changed files with 124 additions and 50 deletions
43
.github/workflows/release.yml
vendored
43
.github/workflows/release.yml
vendored
|
|
@ -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: |
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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], {
|
||||
|
|
|
|||
|
|
@ -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...');
|
||||
|
|
|
|||
|
|
@ -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' },
|
||||
|
|
|
|||
Loading…
Reference in a new issue