Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
72 changes: 65 additions & 7 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -36,20 +36,35 @@ jobs:
exit 1
fi

- name: Check CHANGELOG.md has entry for version
- name: Check CHANGELOG.md has release notes for version
run: |
VERSION="${{ steps.read.outputs.VERSION }}"
if [ ! -f CHANGELOG.md ]; then
echo "::error::CHANGELOG.md does not exist at the repository root."
exit 1
fi
# Accept either `## [X.Y.Z]` or `## X.Y.Z` headings, with an
# optional trailing space (followed by `— DATE`) or end-of-line.
if ! grep -qE "^## \[?${VERSION}\]?( |$)" CHANGELOG.md; then
echo "::error::CHANGELOG.md is missing an entry for version ${VERSION}."
echo "::error::Add a heading like \`## [${VERSION}] — $(date +%Y-%m-%d)\` describing the release before re-running."
exit 1
# A release is valid two ways:
# 1. An explicit `## [X.Y.Z]` / `## X.Y.Z` heading already exists
# (notes written by hand), OR
# 2. The `## [Unreleased]` section is non-empty — the
# post-publish `changelog-rollover` job stamps it as
# `## [X.Y.Z] — DATE` after publishing.
if grep -qE "^## \[?${VERSION}\]?( |$)" CHANGELOG.md; then
echo "Found explicit CHANGELOG heading for ${VERSION}."
exit 0
fi
unreleased_content=$(awk '
/^## \[Unreleased\]/ { inblock=1; next }
inblock && /^## / { inblock=0 }
inblock && NF { print }
' CHANGELOG.md)
if [ -n "$unreleased_content" ]; then
echo "No explicit ${VERSION} heading, but [Unreleased] has content — it will be rolled over after publish."
exit 0
fi
echo "::error::CHANGELOG.md has no release notes for ${VERSION}."
echo "::error::Add entries under \`## [Unreleased]\` (preferred — they roll over automatically), or a \`## [${VERSION}] — $(date +%Y-%m-%d)\` heading, before re-running."
exit 1

build:
needs: version
Expand Down Expand Up @@ -428,3 +443,46 @@ jobs:
uses: pypa/gh-action-pypi-publish@ed0c53931b1dc9bd32cbe73a98c7f6766f8a527e # v1.13.0
with:
packages-dir: dist/

# After every artifact has published, stamp the CHANGELOG: promote the
# `## [Unreleased]` section to `## [<version>] — <date>` and leave a fresh
# empty `[Unreleased]` for the next cycle, then commit it back to the
# release branch. Idempotent — a no-op when a `## [<version>]` heading was
# written by hand. Runs last so a failed publish never rewrites history.
changelog-rollover:
needs: [version, github-release, cargo-publish, npm-publish, pypi-publish]
if: ${{ !inputs.dry-run }}
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- name: Checkout release branch
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
ref: ${{ github.ref_name }}
# persist-credentials defaults to true so the rollover commit can
# be pushed back to the branch below.

- name: Roll [Unreleased] over to the released version
env:
VERSION: ${{ needs.version.outputs.version }}
run: bash scripts/rollover-changelog.sh "$VERSION"

- name: Commit and push if changed
# Pass workflow contexts through env vars (never interpolate
# `${{ }}` directly into the shell) so a branch name can't inject
# code into this run block — see zizmor's template-injection audit.
env:
VERSION: ${{ needs.version.outputs.version }}
REF_NAME: ${{ github.ref_name }}
run: |
if git diff --quiet -- CHANGELOG.md; then
echo "CHANGELOG.md unchanged (heading already present); nothing to commit."
exit 0
fi
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
git add CHANGELOG.md
# [skip ci] so this housekeeping commit doesn't retrigger CI.
git commit -m "chore(changelog): roll [Unreleased] over to v${VERSION} [skip ci]"
git push origin "HEAD:${REF_NAME}"
14 changes: 14 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,20 @@ in this file — see `.github/workflows/release.yml` (`version` job).

## [Unreleased]

### Added

- **Inline OpenVEX generation on `apply` and `scan` via `--vex <path>`.** A
single successful `apply`/`scan` can now both patch and emit the OpenVEX
0.2.0 attestation, instead of requiring a separate `socket-patch vex` step.
The `--vex-product` / `--vex-no-verify` / `--vex-doc-id` / `--vex-compact`
flags mirror the standalone `vex` knobs (and reuse the `SOCKET_VEX_*` env
vars). The document is always written to the given path (never stdout, so it
never races `--json`), built from the post-run manifest and verified against
on-disk state. JSON output gains a top-level `vex` summary
(`{ path, statements, format }`). A requested-but-failed VEX makes the
command exit non-zero even when the apply/scan itself succeeded, surfacing a
stable error code in the envelope.

## [3.2.0] — 2026-05-29

A repo-wide correctness, security, and filesystem-safety hardening pass: every
Expand Down
30 changes: 30 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -176,6 +176,8 @@ socket-patch scan [options]
| `--sync` | Sugar for `--apply --prune`. The canonical bot-mode flag. |
| `--batch-size <n>` | Packages per API request (default: `100`) |
| `--all-releases` | Store patches for every release/distribution variant, not just the installed one — makes the manifest portable across environments (e.g. cross-platform CI caches) |
| `--vex <path>` | On a successful scan, also write an OpenVEX 0.2.0 document to this path. See [Inline VEX generation](#inline-vex-on-apply--scan). (env: `SOCKET_VEX`) |
| `--vex-product`, `--vex-no-verify`, `--vex-doc-id`, `--vex-compact` | Passthrough to the embedded VEX builder; mirror the standalone [`vex`](#vex) knobs. Inert unless `--vex` is set. |

> Use `--dry-run` to preview what `--apply`/`--prune`/`--sync` would do without mutating disk.

Expand Down Expand Up @@ -204,6 +206,9 @@ socket-patch scan --ecosystems npm

# Scan global packages
socket-patch scan -g

# Scan + apply + emit an OpenVEX attestation in one pass
socket-patch scan --json --sync --yes --vex socket.vex.json
```

### `apply`
Expand All @@ -219,6 +224,8 @@ socket-patch apply [options]
| Flag | Description |
|------|-------------|
| `-f, --force` | Skip pre-application hash verification (apply even if package version differs) |
| `--vex <path>` | On a successful apply, also write an OpenVEX 0.2.0 document to this path. See [Inline VEX generation](#inline-vex-on-apply--scan). (env: `SOCKET_VEX`) |
| `--vex-product`, `--vex-no-verify`, `--vex-doc-id`, `--vex-compact` | Passthrough to the embedded VEX builder; mirror the standalone [`vex`](#vex) knobs. Inert unless `--vex` is set. |

**Examples:**
```bash
Expand All @@ -236,6 +243,9 @@ socket-patch apply --offline

# JSON output for CI/CD
socket-patch apply --json

# Apply and emit an OpenVEX attestation in one step
socket-patch apply --vex socket.vex.json
```

### `rollback`
Expand Down Expand Up @@ -469,6 +479,26 @@ trivy image --vex socket.vex.json <image>

Run `socket-patch get` or `socket-patch scan --sync` first — `vex` errors with `no_patches` against an empty manifest.

### Inline VEX on `apply` / `scan`

You don't need a separate `vex` invocation: pass `--vex <path>` to `apply` or `scan` and the same OpenVEX document is generated as a side-effect of a successful run.

```bash
# Patch and attest in one step
socket-patch apply --vex socket.vex.json

# Discover, apply, prune, and attest — the full bot-mode pass
socket-patch scan --json --sync --yes --vex socket.vex.json
```

The `--vex-product`, `--vex-no-verify`, `--vex-doc-id`, and `--vex-compact` flags mirror the standalone command's `--product` / `--no-verify` / `--doc-id` / `--compact` knobs.

Contract:

- The document is **always written to the file** (never stdout), so it never collides with the command's own `--json` output. JSON mode adds a top-level `vex` summary — `{ path, statements, format }` — to the envelope (`apply`) / result (`scan`).
- It's built from the manifest **as it stands after the run** (including any `--apply`/`--sync` writes) and verified against on-disk state unless `--vex-no-verify` is set. Generated for real applies, `--dry-run`, and read-only scans alike.
- **Fail-the-command:** if `--vex` was requested but generation fails (no detectable product, empty/missing manifest, nothing verified, unwritable path), the command exits non-zero **even when the apply/scan itself succeeded**, with a stable error code in the JSON output.

## Scripting & CI/CD

All commands support `--json` for machine-readable output. JSON responses always include a `"status"` field for easy error detection:
Expand Down
15 changes: 15 additions & 0 deletions crates/socket-patch-cli/CLI_CONTRACT.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,8 @@ Beyond the globals above, each subcommand defines a small set of local arguments
| Subcommand | Local arg | Env var | Purpose |
|---|---|---|---|
| `apply` | `--force` / `-f` | `SOCKET_FORCE` | Bypass beforeHash check |
| `apply`, `scan` | `--vex` | `SOCKET_VEX` | Generate an OpenVEX 0.2.0 document at this path on a successful run; see "embedded VEX" below |
| `apply`, `scan` | `--vex-product`, `--vex-no-verify`, `--vex-doc-id`, `--vex-compact` | `SOCKET_VEX_PRODUCT`, `SOCKET_VEX_NO_VERIFY`, `SOCKET_VEX_DOC_ID`, `SOCKET_VEX_COMPACT` | Passthrough to the embedded VEX builder; mirror the standalone `vex` knobs. Inert unless `--vex` is set |
| `scan` | `--apply` / `--prune` / `--sync` | — | Mode selectors (sync = apply + prune) |
| `scan` | `--batch-size` | `SOCKET_BATCH_SIZE` | API batch chunk size (default `100`) |
| `get` | positional `identifier`; `--id` / `--cve` / `--ghsa` / `--package` (`-p`); `--save-only` (alias `--no-apply`); `--one-off` | `SOCKET_SAVE_ONLY`, `SOCKET_ONE_OFF` | Patch lookup + save-vs-apply mode |
Expand All @@ -73,6 +75,18 @@ Beyond the globals above, each subcommand defines a small set of local arguments

The hidden alias `--no-apply` on `get --save-only` is **part of the contract** — it does not appear in `--help` but is widely used in existing scripts.

### Embedded VEX (`apply --vex` / `scan --vex`)

`--vex <path>` folds OpenVEX 0.2.0 generation into `apply` and `scan`: on a successful run the command writes the document to `<path>` using the same engine as the standalone `vex` command. The `--vex-*` flags mirror `vex`'s `--product` / `--no-verify` / `--doc-id` / `--compact` knobs (namespaced to avoid colliding with the host command), and reuse the standalone env vars (`SOCKET_VEX_PRODUCT`, etc.). They are inert unless `--vex` is set.

Contract details:

* **Always written to the file** — never stdout — so the document never races the command's own `--json` output.
* **Fail-the-command**: if `--vex` was requested but generation fails (product PURL undetectable, empty/missing manifest, all patches unverified, unwritable path), the command exits non-zero **even when the apply/scan itself succeeded**. In `--json` mode the failure surfaces in the envelope's `error` (`apply`) / top-level `error` (`scan`), with a stable code (`product_undetected`, `no_applicable_patches`, `write_failed`, …).
* **Built from the post-run manifest**, verified against on-disk state (unless `--vex-no-verify`). Generated for real applies, `--dry-run`, and read-only `scan` alike.
* **JSON success surface**: `apply` adds a top-level `vex` object to its envelope; `scan` adds a top-level `vex` key to its result. Both carry `{ path, statements, format: "openvex-0.2.0" }`.
* `apply`'s no-manifest early exit (the "No .socket folder found" success no-op) does **not** trigger VEX generation — there is nothing to attest.

`repair` keeps its `gc` visible alias.

## Environment variables
Expand Down Expand Up @@ -105,6 +119,7 @@ All v3.0 env vars use the `SOCKET_*` prefix. Three legacy `SOCKET_PATCH_*` names
| `SOCKET_ONE_OFF` | `get --one-off` / `rollback --one-off` | `false` | Local to `get`/`rollback`. |
| `SOCKET_SKIP_ROLLBACK` | `remove --skip-rollback` | `false` | Local to `remove`. |
| `SOCKET_DOWNLOAD_ONLY` | `repair --download-only` | `false` | Local to `repair`. |
| `SOCKET_VEX` | `apply --vex` / `scan --vex` | (none) | Embedded OpenVEX output path. The `SOCKET_VEX_*` knobs (`_PRODUCT`, `_NO_VERIFY`, `_DOC_ID`, `_COMPACT`) are shared with the standalone `vex` command; on `apply`/`scan` they bind to `--vex-product` etc. |

### Deprecated env vars

Expand Down
65 changes: 64 additions & 1 deletion crates/socket-patch-cli/src/commands/apply.rs
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,10 @@ use std::time::Duration;
use tempfile::TempDir;

use crate::args::{apply_env_toggles, GlobalArgs};
use crate::commands::vex::{generate_vex_from_manifest_path, VexEmbedArgs};
use crate::json_envelope::{
AppliedVia, Command, Envelope, EnvelopeError, PatchAction, PatchEvent, PatchEventFile, Status,
VexSummary,
};

/// Overlay every regular file from `src` into `dst` via hard link (falling
Expand Down Expand Up @@ -68,6 +70,13 @@ pub struct ApplyArgs {
/// Skip pre-application hash verification (apply even if package version differs).
#[arg(short = 'f', long, env = "SOCKET_FORCE", default_value_t = false)]
pub force: bool,

/// On a successful apply, also generate an OpenVEX 0.2.0 document.
/// `--vex <path>` is the trigger; the `--vex-*` knobs mirror the
/// standalone `vex` command. A requested-but-failed VEX makes the
/// whole command exit non-zero even when patches applied cleanly.
#[command(flatten)]
pub vex: VexEmbedArgs,
}

/// True when every file the engine verified for this package is already
Expand Down Expand Up @@ -282,6 +291,23 @@ pub async fn run(args: ApplyArgs) -> i32 {
.filter(|r| r.success && !r.files_patched.is_empty())
.count();

// Embedded VEX: only on a successful apply and only when
// `--vex <path>` was passed. Re-read the manifest fresh so
// verification observes the just-applied on-disk state. The
// result is folded into the JSON envelope / human output
// below and flips the exit code on failure (per the
// fail-the-command contract). `None` => not requested.
let vex_result = if success && args.vex.vex.is_some() {
let params = args.vex.to_build_params();
Some(
generate_vex_from_manifest_path(&args.common, &params, &manifest_path)
.await,
)
} else {
None
};
let vex_failed = matches!(vex_result, Some(Err(_)));

if args.common.json {
let mut env = Envelope::new(Command::Apply);
env.dry_run = args.common.dry_run;
Expand Down Expand Up @@ -312,6 +338,19 @@ pub async fn run(args: ApplyArgs) -> i32 {
if !success {
env.mark_partial_failure();
}
match &vex_result {
Some(Ok(summary)) => {
env.vex = Some(VexSummary {
path: args.vex.vex.as_ref().unwrap().display().to_string(),
statements: summary.statements,
format: "openvex-0.2.0".to_string(),
});
}
Some(Err(e)) => {
env.mark_error(EnvelopeError::new(e.code, e.message.clone()));
}
None => {}
}
println!("{}", env.to_pretty_json());
} else if !args.common.silent && !results.is_empty() {
let patched: Vec<_> = results.iter().filter(|r| r.success).collect();
Expand Down Expand Up @@ -389,14 +428,38 @@ pub async fn run(args: ApplyArgs) -> i32 {
}
}

// Human-readable VEX status (JSON mode already folded the
// outcome into the envelope above).
if !args.common.json && !args.common.silent {
match &vex_result {
Some(Ok(summary)) => {
println!(
"Wrote OpenVEX document with {} statement(s) to {}",
summary.statements,
args.vex.vex.as_ref().unwrap().display(),
);
}
Some(Err(e)) => {
eprintln!("Error: VEX generation failed: {}", e.message);
}
None => {}
}
}

// Track telemetry
if success {
track_patch_applied(patched_count, args.common.dry_run, api_token.as_deref(), org_slug.as_deref()).await;
} else {
track_patch_apply_failed("One or more patches failed to apply", args.common.dry_run, api_token.as_deref(), org_slug.as_deref()).await;
}

if success { 0 } else { 1 }
// A requested-but-failed VEX flips an otherwise-successful
// apply to a non-zero exit (fail-the-command contract).
if success && !vex_failed {
0
} else {
1
}
}
Err(e) => {
track_patch_apply_failed(&e, args.common.dry_run, api_token.as_deref(), org_slug.as_deref()).await;
Expand Down
6 changes: 6 additions & 0 deletions crates/socket-patch-cli/src/commands/get.rs
Original file line number Diff line number Diff line change
Expand Up @@ -949,6 +949,9 @@ pub async fn download_and_apply_patches(
..crate::args::GlobalArgs::default()
},
force: false,
// get drives apply internally; embedded VEX is opt-in on the
// top-level command, never on this internal invocation.
vex: Default::default(),
};
let code = super::apply::run(apply_args).await;
apply_succeeded = code == 0;
Expand Down Expand Up @@ -1530,6 +1533,9 @@ async fn save_and_apply_patch(
..crate::args::GlobalArgs::default()
},
force: false,
// get drives apply internally; embedded VEX is opt-in on the
// top-level command, never on this internal invocation.
vex: Default::default(),
};
let code = super::apply::run(apply_args).await;
apply_succeeded = code == 0;
Expand Down
Loading
Loading