diff --git a/.github/workflows/codex-security.yml b/.github/workflows/codex-security.yml new file mode 100644 index 0000000000..0749ec7d84 --- /dev/null +++ b/.github/workflows/codex-security.yml @@ -0,0 +1,244 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +name: Codex Security Release Qualification + +on: + push: + tags: + - "v*.*.*-pre.*" + workflow_call: + inputs: + candidate_ref: + description: Pre-release tag to scan (vMAJOR.MINOR.PATCH-pre.N) + required: true + type: string + stable_ref: + description: Optional previous stable tag override + required: false + default: "" + type: string + allow_full_bootstrap: + description: Allow a full scan when no previous stable tag exists + required: false + default: false + type: boolean + outputs: + base_sha: + description: Previous stable commit, empty for a full bootstrap scan + value: ${{ jobs.analyze.outputs.base_sha }} + candidate_sha: + description: Qualified pre-release commit + value: ${{ jobs.analyze.outputs.candidate_sha }} + category: + description: Code Scanning category for the release train + value: ${{ jobs.analyze.outputs.category }} + train: + description: Stable version targeted by the pre-release train + value: ${{ jobs.analyze.outputs.train }} + secrets: + CODEX_SECURITY_API_KEY: + required: true + workflow_dispatch: + inputs: + candidate_ref: + description: Pre-release tag to scan (vMAJOR.MINOR.PATCH-pre.N) + required: true + type: string + stable_ref: + description: Optional previous stable tag override + required: false + type: string + allow_full_bootstrap: + description: Allow a full scan when no previous stable tag exists + required: false + default: false + type: boolean + +permissions: + actions: read + contents: read + security-events: write + +concurrency: + group: codex-security-release-qualification + cancel-in-progress: true + +env: + CODEX_SECURITY_REASONING_EFFORT: medium + NVIDIA_INFERENCE_BASE_URL: https://inference-api.nvidia.com/v1 + NVIDIA_INFERENCE_MODEL: openai/openai/gpt-5.6-sol + +jobs: + analyze: + name: Codex Security (${{ inputs.candidate_ref || github.ref_name }}) + runs-on: ubuntu-latest + timeout-minutes: 120 + outputs: + base_sha: ${{ steps.range.outputs.base_sha }} + candidate_sha: ${{ steps.range.outputs.candidate_sha }} + category: ${{ steps.range.outputs.category }} + train: ${{ steps.range.outputs.train }} + steps: + # Install the trusted scanner before repository-controlled files exist in + # the workspace, and invoke it later through its absolute path. + - name: Set up Node.js + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: "26" + package-manager-cache: false + + - name: Set up Python + uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 + with: + python-version: "3.14" + + - name: Install Codex Security + run: | + set -euo pipefail + npm install \ + --prefix "$RUNNER_TEMP/codex-security" \ + --ignore-scripts \ + --no-audit \ + --no-fund \ + @openai/codex-security@0.1.24 + + - name: Verify Codex Security + env: + CODEX_SECURITY_BIN: ${{ runner.temp }}/codex-security/node_modules/.bin/codex-security + run: | + set -euo pipefail + test -x "$CODEX_SECURITY_BIN" + "$CODEX_SECURITY_BIN" --version + + - name: Check out the pre-release + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ inputs.candidate_ref || github.ref }} + fetch-depth: 0 + persist-credentials: false + + - name: Resolve release range + id: range + env: + ALLOW_FULL_BOOTSTRAP: ${{ inputs.allow_full_bootstrap || false }} + CANDIDATE_REF: ${{ inputs.candidate_ref || github.ref_name }} + STABLE_REF: ${{ inputs.stable_ref || '' }} + run: | + set -euo pipefail + git show-ref --verify --quiet refs/remotes/origin/main + + args=( + --candidate "$CANDIDATE_REF" + --main-ref origin/main + ) + if [ -n "$STABLE_REF" ]; then + args+=(--stable "$STABLE_REF") + fi + if [ "$ALLOW_FULL_BOOTSTRAP" = "true" ]; then + args+=(--allow-full-bootstrap) + fi + + node tasks/scripts/codex-security-release-range.mjs "${args[@]}" + + - name: Scan changes since the previous stable + env: + BASE_SHA: ${{ steps.range.outputs.base_sha }} + CODEX_SECURITY_BIN: ${{ runner.temp }}/codex-security/node_modules/.bin/codex-security + CODEX_SECURITY_STATE_DIR: ${{ runner.temp }}/codex-security-state + HEAD_SHA: ${{ steps.range.outputs.candidate_sha }} + NVIDIA_INFERENCE_API_KEY: ${{ secrets.CODEX_SECURITY_API_KEY }} + OPENAI_API_KEY: ${{ secrets.CODEX_SECURITY_API_KEY }} + SCAN_DIR: ${{ runner.temp }}/codex-security-results + SCAN_SCOPE: ${{ steps.range.outputs.scan_scope }} + run: | + set -euo pipefail + install -d -m 700 "$CODEX_SECURITY_STATE_DIR" "$SCAN_DIR" + + target_args=() + if [ "$SCAN_SCOPE" = "diff" ]; then + target_args=(--diff "$BASE_SHA" --head "$HEAD_SHA") + elif [ "$SCAN_SCOPE" != "full" ]; then + echo "::error::Unsupported Codex Security scan scope: $SCAN_SCOPE" + exit 2 + fi + + "$CODEX_SECURITY_BIN" scan . \ + "${target_args[@]}" \ + --auth api-key \ + --model "$NVIDIA_INFERENCE_MODEL" \ + --effort "$CODEX_SECURITY_REASONING_EFFORT" \ + --codex 'model_provider="nvidia"' \ + --codex 'model_providers.nvidia.name="NVIDIA Inference"' \ + --codex "model_providers.nvidia.base_url=\"$NVIDIA_INFERENCE_BASE_URL\"" \ + --codex 'model_providers.nvidia.env_key="NVIDIA_INFERENCE_API_KEY"' \ + --codex 'model_providers.nvidia.wire_api="responses"' \ + --codex 'model_providers.nvidia.supports_websockets=false' \ + --output-dir "$SCAN_DIR" \ + --headless > /dev/null + + - name: Export SARIF + env: + CODEX_SECURITY_BIN: ${{ runner.temp }}/codex-security/node_modules/.bin/codex-security + SARIF_FILE: ${{ runner.temp }}/codex-security.sarif + SCAN_DIR: ${{ runner.temp }}/codex-security-results + run: | + set -euo pipefail + "$CODEX_SECURITY_BIN" export "$SCAN_DIR" \ + --export-format sarif \ + --source-root "$GITHUB_WORKSPACE" \ + --output "$SARIF_FILE" + + - name: Summarize findings + env: + BASE_TAG: ${{ steps.range.outputs.base_tag }} + CANDIDATE_TAG: ${{ steps.range.outputs.candidate_tag }} + COMMIT_COUNT: ${{ steps.range.outputs.commit_count }} + SARIF_FILE: ${{ runner.temp }}/codex-security.sarif + SCAN_SCOPE: ${{ steps.range.outputs.scan_scope }} + TRAIN: ${{ steps.range.outputs.train }} + run: | + set -euo pipefail + finding_count=$(jq '[.runs[]?.results[]?] | length' "$SARIF_FILE") + { + echo "### Codex Security release qualification" + echo + echo "- Train: \`$TRAIN\`" + echo "- Candidate: \`$CANDIDATE_TAG\`" + if [ "$SCAN_SCOPE" = "diff" ]; then + echo "- Previous stable: \`$BASE_TAG\`" + echo "- Commits in cumulative diff: $COMMIT_COUNT" + else + echo "- Scope: approved full bootstrap scan" + fi + echo "- Coverage: complete" + echo "- Findings: $finding_count" + echo + echo "Findings are informational during the observation phase." + } >> "$GITHUB_STEP_SUMMARY" + + - name: Upload SARIF to Code Scanning + uses: github/codeql-action/upload-sarif@ff2f1c621b7f889edc0d3c761ac2e6a3f8cdb0dd # v4.37.7 + with: + sarif_file: ${{ runner.temp }}/codex-security.sarif + ref: refs/heads/main + sha: ${{ steps.range.outputs.candidate_sha }} + category: ${{ steps.range.outputs.category }} + + result: + name: OpenShell / Codex Security (informational) + if: always() + needs: analyze + runs-on: ubuntu-latest + permissions: {} + steps: + - name: Evaluate scanner execution + env: + ANALYZE_RESULT: ${{ needs.analyze.result }} + run: | + set -euo pipefail + if [ "$ANALYZE_RESULT" != "success" ]; then + echo "::error::Codex Security release qualification did not complete successfully." + exit 1 + fi + echo "Codex Security completed; findings remain informational." diff --git a/.github/workflows/release-tag.yml b/.github/workflows/release-tag.yml index 92eb4adf14..20d74436c6 100644 --- a/.github/workflows/release-tag.yml +++ b/.github/workflows/release-tag.yml @@ -4,6 +4,7 @@ on: push: tags: - "v*.*.*" + - "!v*.*.*-pre.*" workflow_dispatch: inputs: tag: diff --git a/CI.md b/CI.md index c0ec57f877..c29abdba0c 100644 --- a/CI.md +++ b/CI.md @@ -28,13 +28,15 @@ The GitHub ruleset should require the `OpenShell / ...` statuses published by `R ## Informational security reports -Security analysis that does not need NVIDIA infrastructure runs directly on -GitHub-hosted runners. These workflows receive no secrets. The PR-oriented -reports run on fork pull requests without waiting for copy-pr-bot. Scanner jobs -request `security-events: write` to publish SARIF to Code Scanning. GitHub -permits Code Scanning uploads from `pull_request` runs even when fork and -Dependabot contexts receive a read-only `GITHUB_TOKEN`, so those scanners upload -results directly and also retain report artifacts: +Security workflow compute runs directly on GitHub-hosted runners instead of +NVIDIA self-hosted runners. The PR-oriented workflows receive no secrets and run +on fork pull requests without waiting for copy-pr-bot. Codex Security release +qualification is the exception: it runs only for maintainer-created +pre-release tags and receives a scoped NVIDIA Inference API key for the scan +step. Scanner jobs request `security-events: write` to publish SARIF to Code +Scanning. GitHub permits Code Scanning uploads from `pull_request` runs even +when fork and Dependabot contexts receive a read-only `GITHUB_TOKEN`, so those +scanners upload results directly: - `Workflow Security Reports` runs Actionlint and Zizmor. Actionlint reports workflow syntax and expression findings. Zizmor reports only High severity, @@ -50,6 +52,20 @@ results directly and also retain report artifacts: E2E test code are excluded. It runs nightly on `main`, remains manually dispatchable for diagnostics, uploads results to Code Scanning, and retains workflow artifacts. +- `Codex Security Release Qualification` analyzes each `vX.Y.Z-pre.N` + candidate against the previous stable release. Every candidate in a release + train therefore rescans the cumulative stable-to-candidate diff. It calls + `https://inference-api.nvidia.com/v1` with + `openai/openai/gpt-5.6-sol` at medium reasoning effort. The + `CODEX_SECURITY_API_KEY` secret must contain an API key authorized for that + NVIDIA endpoint. Results are uploaded against the candidate commit on `main` + under a category shared by the train, for example + `codex-security/v0.1.1`, so later candidates replace earlier analyses. The + slash-qualified NVIDIA model identifier prevents Codex Security 0.1.24 from + enforcing `--max-cost`, so the workflow relies on its timeout, serialized + concurrency, and the inference account's spend controls. Raw reports are not + retained as workflow artifacts. Pre-release creation and stable-promotion + enforcement remain part of RFC 0014 and are not implemented by this workflow. Findings do not fail these workflows. Tool startup, configuration, build, and analysis failures still fail so a broken scanner cannot appear healthy. The @@ -187,6 +203,7 @@ The bot's full administrator documentation is internal to NVIDIA. The only comma | `.github/workflows/workflow-security.yml` | Runs informational Actionlint and High-severity Zizmor reports on GitHub-hosted runners. | | `.github/workflows/dependency-review.yml` | Reports dependency changes when GitHub Dependency Graph is available; otherwise publishes a neutral warning. | | `.github/workflows/codeql.yml` | Runs nightly informational CodeQL analysis on `main` for Rust and the Go, Python, and TypeScript SDKs and retains SARIF artifacts. | +| `.github/workflows/codex-security.yml` | Scans the cumulative diff from the previous stable release to each pre-release candidate and publishes train-scoped SARIF on `main`. | ## Release workflows @@ -195,7 +212,7 @@ These workflows run after merge to publish dev/tagged artifacts and verify them. | File | Role | |---|---| | `.github/workflows/release-dev.yml` | Publishes the rolling `dev` build on every push to `main`. Builds gateway/supervisor images and binaries, packages, wheels, and pushes the Helm chart as `oci://ghcr.io/nvidia/openshell/helm-chart:0.0.0-dev` (plus an immutable `0.0.0-dev.` pin). Also dispatchable manually. | -| `.github/workflows/release-tag.yml` | Publishes a tagged public release. | +| `.github/workflows/release-tag.yml` | Publishes a tagged stable release. Its automatic tag trigger excludes `-pre.*`; manual dispatch remains maintainer-controlled. | | `.github/workflows/release-canary.yml` | Smoke-tests published artifacts on `macos`, `ubuntu`, `fedora`, and `kubernetes` (kind + Helm) runners. Triggers automatically when `Release Dev` succeeds, and via `workflow_dispatch` on any branch (`gh workflow run release-canary.yml --ref `). The `kubernetes` job pins to `0.0.0-dev` artifacts; the other jobs install the latest tagged release via `install.sh`. See the `test-release-canary` skill for the manual-dispatch playbook and local kind reproduction. | ## Required status contexts diff --git a/architecture/build.md b/architecture/build.md index c6810feece..06cba5848d 100644 --- a/architecture/build.md +++ b/architecture/build.md @@ -284,22 +284,31 @@ disables emission for Rust tests, E2E runs, and release canaries. This prevents synthetic activity from contributing to product usage metrics. Static security checks are deliberately outside the mirror-branch path. They run -directly on GitHub-hosted runners with no secrets, so the pull-request-triggered -ones also cover fork pull requests, and none of them consume NVIDIA self-hosted -capacity. Scanner jobs request `security-events: write` and upload SARIF to Code -Scanning directly on every event they run on, including fork and Dependabot pull -requests, which Code Scanning permits for `pull_request` runs despite their -read-only `GITHUB_TOKEN`. Each scanner also retains its report as a workflow -artifact. No privileged intermediate workflow relays those uploads. +directly on GitHub-hosted runners and none of them consume NVIDIA self-hosted +capacity. The change-oriented ones receive no secrets, so they also cover fork +pull requests; Codex Security release qualification is the exception because it +needs a scoped API key. That key routes Codex Security's model calls to +NVIDIA-hosted inference; the job itself still runs on a GitHub-hosted runner and +uses no NVIDIA self-hosted runner. Scanner jobs request `security-events: write` +and upload SARIF to Code Scanning directly on every event they run on, including +fork and Dependabot pull requests, which Code Scanning permits for +`pull_request` runs despite their read-only `GITHUB_TOKEN`. No privileged +intermediate workflow relays those uploads. Report retention differs by scanner: +Actionlint, Zizmor, and CodeQL keep their reports as workflow artifacts, and +Codex Security keeps no raw report. Triggers differ by workflow: `.github/workflows/workflow-security.yml` runs on `pull_request`, `merge_group`, `main`, and a weekly schedule; `.github/workflows/dependency-review.yml` runs on `pull_request` and -`merge_group` only, because it needs a base and head commit to compare; and +`merge_group` only, because it needs a base and head commit to compare; `.github/workflows/codeql.yml` runs nightly on the default branch (`main`) via -`schedule`, with `workflow_dispatch` kept for manual diagnostics. CodeQL does +`schedule`, with `workflow_dispatch` kept for manual diagnostics; and +`.github/workflows/codex-security.yml` runs on pushed `v*.*.*-pre.*` tags, and is +also callable through `workflow_call` and `workflow_dispatch`. CodeQL does not run on `pull_request`, `merge_group`, or pushes to `main`, so it reports repository-level Code Scanning state on the default branch instead of per-PR results, and its four-language matrix stays off the per-change critical path. +Codex Security is release-scoped rather than change-scoped, so it never runs on +a pull request or merge group. - **Actionlint and Zizmor** analyze the workflow definitions themselves. Repository configuration lives in `.github/actionlint.yml` (self-hosted runner @@ -324,12 +333,49 @@ results, and its four-language matrix stays off the per-change critical path. Go requires a build; the other languages use build mode `none`. Analysis runs on the nightly schedule or by manual dispatch. Results are uploaded to Code Scanning and always retained as workflow artifacts. +- **Codex Security** qualifies release candidates rather than individual + changes. The job installs a pinned `@openai/codex-security` release into the + runner temp directory before the repository is checked out and invokes it by + absolute path, so repository-controlled files cannot shadow the scanner. Model + calls go to NVIDIA-hosted inference at `https://inference-api.nvidia.com/v1`, + declared as a custom Codex provider named `nvidia` that uses the Responses + wire API with WebSockets disabled. The scan runs `openai/openai/gpt-5.6-sol` + at `medium` reasoning effort. The `CODEX_SECURITY_API_KEY` secret holds the + NVIDIA key and is exposed to the scan step alone, as `OPENAI_API_KEY` so the + CLI selects API-key auth and as `NVIDIA_INFERENCE_API_KEY`, the provider + `env_key` read by the Codex child process. + `tasks/scripts/codex-security-release-range.mjs` resolves the scan range: the + candidate must be a `vX.Y.Z-pre.N` tag that is an ancestor of `origin/main`, + and the base is the newest stable `vX.Y.Z` tag merged into the candidate that + is strictly older than the release train `vX.Y.Z` the candidate targets. A + full-repository scan is only possible when no such stable tag exists and the + caller passes `allow_full_bootstrap`. Each candidate scans the cumulative + stable-to-candidate diff, so later candidates re-cover earlier ones. SARIF is + uploaded against `refs/heads/main` at the candidate commit under the + train-scoped category `codex-security/vX.Y.Z`, which makes each candidate's + analysis replace the previous one for that train. Codex Security 0.1.24 cannot + apply `--max-cost` to a slash-qualified model identifier, so the run has no + CLI-enforced cost ceiling. Spend is bounded instead by the 120-minute job + timeout, a single repository-wide concurrency group that serializes + qualification so starting a newer candidate cancels an in-flight one, and + NVIDIA account-side controls. No raw report is retained. Findings never fail these checks; scanner and build failures do. A scanner that -cannot run, a CodeQL analyzer that does not complete, and an unexpected -Dependency Graph API error are all errors, which keeps an informational check -from silently degrading into a no-op. None of these checks are required -statuses, so they do not gate merges. +cannot run, a CodeQL analyzer that does not complete, an unexpected Dependency +Graph API error, and a Codex Security range, scan, or export failure are all +errors, which keeps an informational check from silently degrading into a no-op. +Codex Security also rejects any scan scope other than the resolved +cumulative diff or an approved full bootstrap, so a qualification run either +covers the whole stable-to-candidate range or fails; a separate no-permission +job republishes the analysis job's outcome as the +`OpenShell / Codex Security (informational)` status. None of these checks are +required statuses, so they do not gate merges. + +Codex Security findings are informational during the observation phase, and the +workflow only reports on candidates that already exist. Creating pre-release +tags and gating stable promotion on qualification results are part of +[RFC 0014](../rfc/0014-release-stability/release-qualification.md) and are not +implemented yet. See `CI.md` for the contributor workflow, labels, and maintainer merge-queue workflow. diff --git a/tasks/scripts/codex-security-release-range.mjs b/tasks/scripts/codex-security-release-range.mjs new file mode 100644 index 0000000000..025df1e7ad --- /dev/null +++ b/tasks/scripts/codex-security-release-range.mjs @@ -0,0 +1,232 @@ +#!/usr/bin/env node +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { execFileSync, spawnSync } from 'node:child_process'; +import { appendFileSync } from 'node:fs'; + +const STABLE_TAG_RE = + /^v(?0|[1-9]\d*)\.(?0|[1-9]\d*)\.(?0|[1-9]\d*)$/; +const PRERELEASE_TAG_RE = + /^v(?0|[1-9]\d*)\.(?0|[1-9]\d*)\.(?0|[1-9]\d*)-pre\.(?[1-9]\d*)$/; + +function versionFromMatch(match) { + return [ + Number(match.groups.major), + Number(match.groups.minor), + Number(match.groups.patch), + ]; +} + +export function parseStableTag(tag) { + const match = STABLE_TAG_RE.exec(tag); + if (match === null) return null; + return { tag, version: versionFromMatch(match) }; +} + +export function parsePrereleaseTag(tag) { + const match = PRERELEASE_TAG_RE.exec(tag); + if (match === null) return null; + const version = versionFromMatch(match); + return { + tag, + version, + prerelease: Number(match.groups.prerelease), + train: `v${version.join('.')}`, + }; +} + +export function compareVersions(left, right) { + for (let index = 0; index < left.length; index += 1) { + if (left[index] !== right[index]) return left[index] - right[index]; + } + return 0; +} + +export function selectPreviousStable(tags, candidateVersion) { + return tags + .map(parseStableTag) + .filter( + (parsed) => + parsed !== null && + compareVersions(parsed.version, candidateVersion) < 0, + ) + .sort((left, right) => compareVersions(right.version, left.version))[0]?.tag; +} + +function parseArguments(argv) { + const options = { + candidate: '', + stable: '', + mainRef: 'origin/main', + allowFullBootstrap: false, + githubOutput: process.env.GITHUB_OUTPUT ?? '', + }; + + for (let index = 0; index < argv.length; index += 1) { + const argument = argv[index]; + switch (argument) { + case '--candidate': + options.candidate = argv[++index] ?? ''; + break; + case '--stable': + options.stable = argv[++index] ?? ''; + break; + case '--main-ref': + options.mainRef = argv[++index] ?? ''; + break; + case '--allow-full-bootstrap': + options.allowFullBootstrap = true; + break; + case '--github-output': + options.githubOutput = argv[++index] ?? ''; + break; + default: + throw new Error(`unknown argument: ${argument}`); + } + } + + if (options.candidate === '') { + throw new Error('--candidate is required'); + } + if (options.mainRef === '') { + throw new Error('--main-ref must not be empty'); + } + return options; +} + +function git(args) { + return execFileSync('git', args, { encoding: 'utf8' }).trim(); +} + +function resolvesToCommit(ref) { + try { + return git(['rev-parse', '--verify', `${ref}^{commit}`]); + } catch { + throw new Error(`Git reference does not resolve to a commit: ${ref}`); + } +} + +function isAncestor(ancestor, descendant) { + const result = spawnSync( + 'git', + ['merge-base', '--is-ancestor', ancestor, descendant], + { stdio: 'ignore' }, + ); + if (result.status === 0) return true; + if (result.status === 1) return false; + throw new Error( + `git merge-base failed for ${ancestor} and ${descendant} (exit ${result.status ?? 'unknown'})`, + ); +} + +function writeOutputs(path, outputs) { + if (path === '') return; + const lines = Object.entries(outputs).map(([key, value]) => `${key}=${value}`); + appendFileSync(path, `${lines.join('\n')}\n`, { encoding: 'utf8' }); +} + +function resolveRange(options) { + const candidate = parsePrereleaseTag(options.candidate); + if (candidate === null) { + throw new Error( + `candidate must match vMAJOR.MINOR.PATCH-pre.N: ${options.candidate}`, + ); + } + + const candidateSha = resolvesToCommit(candidate.tag); + const mainSha = resolvesToCommit(options.mainRef); + if (!isAncestor(candidateSha, mainSha)) { + throw new Error( + `candidate ${candidate.tag} (${candidateSha}) is not an ancestor of ${options.mainRef}`, + ); + } + + const mergedTags = git(['tag', '--list', 'v*', '--merged', candidateSha]) + .split('\n') + .filter(Boolean); + const stableTag = + options.stable || selectPreviousStable(mergedTags, candidate.version); + + if (stableTag === undefined || stableTag === '') { + if (!options.allowFullBootstrap) { + throw new Error( + `no previous stable tag exists for ${candidate.tag}; rerun with an approved base or --allow-full-bootstrap`, + ); + } + + return { + base_tag: '', + base_sha: '', + candidate_tag: candidate.tag, + candidate_sha: candidateSha, + train: candidate.train, + category: `codex-security/${candidate.train}`, + scan_scope: 'full', + commit_count: git(['rev-list', '--count', candidateSha]), + }; + } + + const stable = parseStableTag(stableTag); + if (stable === null) { + throw new Error( + `stable base must match vMAJOR.MINOR.PATCH without a prerelease: ${stableTag}`, + ); + } + if (compareVersions(stable.version, candidate.version) >= 0) { + throw new Error( + `stable base ${stable.tag} must be older than release train ${candidate.train}`, + ); + } + + const stableSha = resolvesToCommit(stable.tag); + if (!isAncestor(stableSha, candidateSha)) { + throw new Error( + `stable base ${stable.tag} (${stableSha}) is not an ancestor of ${candidate.tag}`, + ); + } + + const commitCount = Number( + git(['rev-list', '--count', `${stableSha}..${candidateSha}`]), + ); + if (!Number.isSafeInteger(commitCount) || commitCount <= 0) { + throw new Error( + `candidate ${candidate.tag} has no commits after stable base ${stable.tag}`, + ); + } + + return { + base_tag: stable.tag, + base_sha: stableSha, + candidate_tag: candidate.tag, + candidate_sha: candidateSha, + train: candidate.train, + category: `codex-security/${candidate.train}`, + scan_scope: 'diff', + commit_count: String(commitCount), + }; +} + +function main() { + try { + const options = parseArguments(process.argv.slice(2)); + const outputs = resolveRange(options); + writeOutputs(options.githubOutput, outputs); + process.stdout.write(`${JSON.stringify(outputs, null, 2)}\n`); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + if (process.env.GITHUB_ACTIONS === 'true') { + process.stderr.write(`::error::${message}\n`); + } else { + process.stderr.write(`codex-security-release-range: ${message}\n`); + } + process.exitCode = 1; + } +} + +if ( + process.argv[1] !== undefined && + import.meta.url === new URL(process.argv[1], 'file:').href +) { + main(); +} diff --git a/tasks/scripts/codex-security-release-range.test.mjs b/tasks/scripts/codex-security-release-range.test.mjs new file mode 100644 index 0000000000..ea017a8825 --- /dev/null +++ b/tasks/scripts/codex-security-release-range.test.mjs @@ -0,0 +1,190 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import assert from 'node:assert/strict'; +import { execFileSync, spawnSync } from 'node:child_process'; +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import test from 'node:test'; + +import { + compareVersions, + parsePrereleaseTag, + parseStableTag, + selectPreviousStable, +} from './codex-security-release-range.mjs'; + +const SCRIPT = fileURLToPath( + new URL('./codex-security-release-range.mjs', import.meta.url), +); + +function git(repository, ...args) { + return execFileSync('git', args, { + cwd: repository, + encoding: 'utf8', + }).trim(); +} + +function commit(repository, name) { + writeFileSync(join(repository, 'content.txt'), `${name}\n`, { + encoding: 'utf8', + }); + git(repository, 'add', 'content.txt'); + git( + repository, + '-c', + 'user.name=Codex Security Test', + '-c', + 'user.email=codex-security-test@example.com', + 'commit', + '-m', + name, + ); +} + +function createRepository() { + const repository = mkdtempSync(join(tmpdir(), 'codex-security-range-')); + git(repository, 'init', '--initial-branch=main'); + return repository; +} + +function runResolver(repository, ...args) { + return JSON.parse( + execFileSync(process.execPath, [SCRIPT, ...args], { + cwd: repository, + encoding: 'utf8', + }), + ); +} + +test('parses strict stable and prerelease tags', () => { + assert.deepEqual(parseStableTag('v0.1.0'), { + tag: 'v0.1.0', + version: [0, 1, 0], + }); + assert.equal(parseStableTag('v0.1.0-pre.1'), null); + assert.equal(parseStableTag('dev'), null); + assert.equal(parseStableTag('v01.1.0'), null); + + assert.deepEqual(parsePrereleaseTag('v2.10.3-pre.12'), { + tag: 'v2.10.3-pre.12', + version: [2, 10, 3], + prerelease: 12, + train: 'v2.10.3', + }); + assert.equal(parsePrereleaseTag('v2.10.3'), null); + assert.equal(parsePrereleaseTag('v2.10.3-pre.0'), null); +}); + +test('selects the newest stable strictly before the candidate train', () => { + assert.equal( + selectPreviousStable( + [ + 'v0.1.9', + 'v0.1.10', + 'v0.2.0-pre.1', + 'v0.2.0', + 'vm-runtime', + ], + [0, 2, 0], + ), + 'v0.1.10', + ); + assert.equal(selectPreviousStable(['v0.1.0'], [0, 1, 0]), undefined); + assert(compareVersions([0, 10, 0], [0, 2, 99]) > 0); +}); + +test('resolves a cumulative prerelease range from Git history', () => { + const repository = createRepository(); + try { + commit(repository, 'stable'); + git(repository, 'tag', 'v0.1.0'); + commit(repository, 'pre one'); + git(repository, 'tag', 'v0.1.1-pre.1'); + commit(repository, 'pre two'); + git(repository, 'tag', 'v0.1.1-pre.2'); + git( + repository, + 'update-ref', + 'refs/remotes/origin/main', + git(repository, 'rev-parse', 'HEAD'), + ); + + const result = runResolver( + repository, + '--candidate', + 'v0.1.1-pre.2', + ); + assert.equal(result.base_tag, 'v0.1.0'); + assert.equal(result.candidate_tag, 'v0.1.1-pre.2'); + assert.equal(result.train, 'v0.1.1'); + assert.equal(result.category, 'codex-security/v0.1.1'); + assert.equal(result.scan_scope, 'diff'); + assert.equal(result.commit_count, '2'); + } finally { + rmSync(repository, { recursive: true, force: true }); + } +}); + +test('rejects a prerelease that is not on main', () => { + const repository = createRepository(); + try { + commit(repository, 'stable'); + git(repository, 'tag', 'v0.1.0'); + git( + repository, + 'update-ref', + 'refs/remotes/origin/main', + git(repository, 'rev-parse', 'HEAD'), + ); + git(repository, 'switch', '--create', 'detached-release'); + commit(repository, 'off-main candidate'); + git(repository, 'tag', 'v0.1.1-pre.1'); + + const result = spawnSync( + process.execPath, + [SCRIPT, '--candidate', 'v0.1.1-pre.1'], + { cwd: repository, encoding: 'utf8' }, + ); + assert.equal(result.status, 1); + assert.match(result.stderr, /is not an ancestor of origin\/main/); + } finally { + rmSync(repository, { recursive: true, force: true }); + } +}); + +test('requires explicit approval before a full bootstrap scan', () => { + const repository = createRepository(); + try { + commit(repository, 'first candidate'); + git(repository, 'tag', 'v0.1.0-pre.1'); + git( + repository, + 'update-ref', + 'refs/remotes/origin/main', + git(repository, 'rev-parse', 'HEAD'), + ); + + const rejected = spawnSync( + process.execPath, + [SCRIPT, '--candidate', 'v0.1.0-pre.1'], + { cwd: repository, encoding: 'utf8' }, + ); + assert.equal(rejected.status, 1); + assert.match(rejected.stderr, /--allow-full-bootstrap/); + + const approved = runResolver( + repository, + '--candidate', + 'v0.1.0-pre.1', + '--allow-full-bootstrap', + ); + assert.equal(approved.scan_scope, 'full'); + assert.equal(approved.base_tag, ''); + assert.equal(approved.train, 'v0.1.0'); + } finally { + rmSync(repository, { recursive: true, force: true }); + } +}); diff --git a/tasks/test.toml b/tasks/test.toml index 29e06b826a..e6f9abddd1 100644 --- a/tasks/test.toml +++ b/tasks/test.toml @@ -13,6 +13,7 @@ depends = [ "test:install-sh", "test:build-env", "test:packaging-assets", + "test:codex-security-release-range", "test:docs-website", ] @@ -45,6 +46,11 @@ run = "tasks/scripts/test-packaging-assets.sh" run_windows = "echo Skipping test:packaging-assets: Linux service and RPM assets do not apply on Windows." hide = true +["test:codex-security-release-range"] +description = "Test Codex Security release-range resolution" +run = "node --test tasks/scripts/codex-security-release-range.test.mjs" +hide = true + [e2e] description = "Run all end-to-end tests (Rust + Python + MCP)" depends = ["e2e:rust", "e2e:python", "e2e:mcp"]