Skip to content

Commit d7f57d9

Browse files
feat(apply,scan): generate OpenVEX document inline via --vex (#94)
* feat(apply,scan): generate OpenVEX document inline via --vex Add an optional `--vex <path>` argument to `apply` and `scan`. On a successful run, the command writes an OpenVEX 0.2.0 document to that path using the same engine as the standalone `vex` command, so a single invocation can both apply/scan patches and emit the attestation — the natural shape for CI and bot workflows. Core refactor: extract the product-resolve -> verify -> build -> serialize -> write -> telemetry pipeline out of `vex::run` into reusable `generate_vex` / `generate_vex_from_manifest_path` helpers (plain VexBuildParams / VexWriteSummary / VexGenError types). The standalone `vex` command now calls this helper with no behavior change. Embedded contract: - `--vex` is the trigger; `--vex-product` / `--vex-no-verify` / `--vex-doc-id` / `--vex-compact` mirror the standalone knobs (namespaced to avoid colliding with apply's --force vocabulary; reuse SOCKET_VEX_*). - Always written to the file, never stdout, so it never races --json. - Fail-the-command: a requested-but-failed VEX flips the exit code even when the apply/scan itself succeeded, surfacing the error in the JSON envelope (apply) / result (scan) with a stable code. - Built from the post-run manifest, verified against on-disk state; generated for real applies, --dry-run, and read-only scans alike. - JSON success adds a top-level `vex` summary { path, statements, format }. Tests: new e2e_embedded_vex.rs (apply parity, envelope field, fail path, scan no-verify success, scan verify-failure error); parse-test coverage in cli_parse_{apply,scan}; update CLI_CONTRACT.md. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * docs: changelog + README for inline --vex on apply/scan Add an Unreleased "Added" changelog entry, document the `--vex` / `--vex-*` flags in the apply & scan README tables with examples, and add an "Inline VEX on apply / scan" subsection covering the fail-the-command contract and JSON summary surface. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * ci(release): auto-roll CHANGELOG [Unreleased] over after publish Add `scripts/rollover-changelog.sh` and a post-publish `changelog-rollover` job. After every artifact publishes, the job promotes `## [Unreleased]` to `## [<version>] — <date>` and leaves a fresh empty `[Unreleased]` for the next cycle, then commits it back to the release branch (`[skip ci]`). The helper is idempotent and runs after publish, so it never fails the release: it's a no-op when a `## [<version>]` heading was written by hand or when `[Unreleased]` is empty, leaving the file byte-identical so there's nothing to commit. To make the new flow usable end-to-end, the pre-publish version-check now accepts a non-empty `[Unreleased]` section as valid release notes (in addition to an explicit `## [X.Y.Z]` heading), so maintainers can just add entries under `[Unreleased]` and let the rollover stamp them. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * chore: nudge CI Empty commit to re-trigger the CI workflow — the previous push (a7ad4cf) did not fire a pull_request run. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * ci(release): fix zizmor template-injection in changelog-rollover The Audit GHA Workflows check (zizmor) flagged a High template-injection: `${{ github.ref_name }}` expanded directly inside the push `run:` block can inject attacker-controllable code via a crafted branch name. Pass workflow contexts (`github.ref_name`, `needs.version.outputs.version`) through `env:` and reference them as shell variables instead. Verified clean with `zizmor .github --gh-token <tok> --min-severity medium` (the exact CI invocation): "No findings to report." Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent a235410 commit d7f57d9

23 files changed

Lines changed: 1058 additions & 116 deletions

.github/workflows/release.yml

Lines changed: 65 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -36,20 +36,35 @@ jobs:
3636
exit 1
3737
fi
3838
39-
- name: Check CHANGELOG.md has entry for version
39+
- name: Check CHANGELOG.md has release notes for version
4040
run: |
4141
VERSION="${{ steps.read.outputs.VERSION }}"
4242
if [ ! -f CHANGELOG.md ]; then
4343
echo "::error::CHANGELOG.md does not exist at the repository root."
4444
exit 1
4545
fi
46-
# Accept either `## [X.Y.Z]` or `## X.Y.Z` headings, with an
47-
# optional trailing space (followed by `— DATE`) or end-of-line.
48-
if ! grep -qE "^## \[?${VERSION}\]?( |$)" CHANGELOG.md; then
49-
echo "::error::CHANGELOG.md is missing an entry for version ${VERSION}."
50-
echo "::error::Add a heading like \`## [${VERSION}] — $(date +%Y-%m-%d)\` describing the release before re-running."
51-
exit 1
46+
# A release is valid two ways:
47+
# 1. An explicit `## [X.Y.Z]` / `## X.Y.Z` heading already exists
48+
# (notes written by hand), OR
49+
# 2. The `## [Unreleased]` section is non-empty — the
50+
# post-publish `changelog-rollover` job stamps it as
51+
# `## [X.Y.Z] — DATE` after publishing.
52+
if grep -qE "^## \[?${VERSION}\]?( |$)" CHANGELOG.md; then
53+
echo "Found explicit CHANGELOG heading for ${VERSION}."
54+
exit 0
5255
fi
56+
unreleased_content=$(awk '
57+
/^## \[Unreleased\]/ { inblock=1; next }
58+
inblock && /^## / { inblock=0 }
59+
inblock && NF { print }
60+
' CHANGELOG.md)
61+
if [ -n "$unreleased_content" ]; then
62+
echo "No explicit ${VERSION} heading, but [Unreleased] has content — it will be rolled over after publish."
63+
exit 0
64+
fi
65+
echo "::error::CHANGELOG.md has no release notes for ${VERSION}."
66+
echo "::error::Add entries under \`## [Unreleased]\` (preferred — they roll over automatically), or a \`## [${VERSION}] — $(date +%Y-%m-%d)\` heading, before re-running."
67+
exit 1
5368
5469
build:
5570
needs: version
@@ -428,3 +443,46 @@ jobs:
428443
uses: pypa/gh-action-pypi-publish@ed0c53931b1dc9bd32cbe73a98c7f6766f8a527e # v1.13.0
429444
with:
430445
packages-dir: dist/
446+
447+
# After every artifact has published, stamp the CHANGELOG: promote the
448+
# `## [Unreleased]` section to `## [<version>] — <date>` and leave a fresh
449+
# empty `[Unreleased]` for the next cycle, then commit it back to the
450+
# release branch. Idempotent — a no-op when a `## [<version>]` heading was
451+
# written by hand. Runs last so a failed publish never rewrites history.
452+
changelog-rollover:
453+
needs: [version, github-release, cargo-publish, npm-publish, pypi-publish]
454+
if: ${{ !inputs.dry-run }}
455+
runs-on: ubuntu-latest
456+
permissions:
457+
contents: write
458+
steps:
459+
- name: Checkout release branch
460+
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
461+
with:
462+
ref: ${{ github.ref_name }}
463+
# persist-credentials defaults to true so the rollover commit can
464+
# be pushed back to the branch below.
465+
466+
- name: Roll [Unreleased] over to the released version
467+
env:
468+
VERSION: ${{ needs.version.outputs.version }}
469+
run: bash scripts/rollover-changelog.sh "$VERSION"
470+
471+
- name: Commit and push if changed
472+
# Pass workflow contexts through env vars (never interpolate
473+
# `${{ }}` directly into the shell) so a branch name can't inject
474+
# code into this run block — see zizmor's template-injection audit.
475+
env:
476+
VERSION: ${{ needs.version.outputs.version }}
477+
REF_NAME: ${{ github.ref_name }}
478+
run: |
479+
if git diff --quiet -- CHANGELOG.md; then
480+
echo "CHANGELOG.md unchanged (heading already present); nothing to commit."
481+
exit 0
482+
fi
483+
git config user.name "github-actions[bot]"
484+
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
485+
git add CHANGELOG.md
486+
# [skip ci] so this housekeeping commit doesn't retrigger CI.
487+
git commit -m "chore(changelog): roll [Unreleased] over to v${VERSION} [skip ci]"
488+
git push origin "HEAD:${REF_NAME}"

CHANGELOG.md

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,20 @@ in this file — see `.github/workflows/release.yml` (`version` job).
1414

1515
## [Unreleased]
1616

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

1933
A repo-wide correctness, security, and filesystem-safety hardening pass: every

README.md

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -176,6 +176,8 @@ socket-patch scan [options]
176176
| `--sync` | Sugar for `--apply --prune`. The canonical bot-mode flag. |
177177
| `--batch-size <n>` | Packages per API request (default: `100`) |
178178
| `--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) |
179+
| `--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`) |
180+
| `--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. |
179181

180182
> Use `--dry-run` to preview what `--apply`/`--prune`/`--sync` would do without mutating disk.
181183
@@ -204,6 +206,9 @@ socket-patch scan --ecosystems npm
204206

205207
# Scan global packages
206208
socket-patch scan -g
209+
210+
# Scan + apply + emit an OpenVEX attestation in one pass
211+
socket-patch scan --json --sync --yes --vex socket.vex.json
207212
```
208213

209214
### `apply`
@@ -219,6 +224,8 @@ socket-patch apply [options]
219224
| Flag | Description |
220225
|------|-------------|
221226
| `-f, --force` | Skip pre-application hash verification (apply even if package version differs) |
227+
| `--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`) |
228+
| `--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. |
222229

223230
**Examples:**
224231
```bash
@@ -236,6 +243,9 @@ socket-patch apply --offline
236243

237244
# JSON output for CI/CD
238245
socket-patch apply --json
246+
247+
# Apply and emit an OpenVEX attestation in one step
248+
socket-patch apply --vex socket.vex.json
239249
```
240250

241251
### `rollback`
@@ -469,6 +479,26 @@ trivy image --vex socket.vex.json <image>
469479

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

482+
### Inline VEX on `apply` / `scan`
483+
484+
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.
485+
486+
```bash
487+
# Patch and attest in one step
488+
socket-patch apply --vex socket.vex.json
489+
490+
# Discover, apply, prune, and attest — the full bot-mode pass
491+
socket-patch scan --json --sync --yes --vex socket.vex.json
492+
```
493+
494+
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.
495+
496+
Contract:
497+
498+
- 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`).
499+
- 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.
500+
- **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.
501+
472502
## Scripting & CI/CD
473503

474504
All commands support `--json` for machine-readable output. JSON responses always include a `"status"` field for easy error detection:

crates/socket-patch-cli/CLI_CONTRACT.md

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,8 @@ Beyond the globals above, each subcommand defines a small set of local arguments
5454
| Subcommand | Local arg | Env var | Purpose |
5555
|---|---|---|---|
5656
| `apply` | `--force` / `-f` | `SOCKET_FORCE` | Bypass beforeHash check |
57+
| `apply`, `scan` | `--vex` | `SOCKET_VEX` | Generate an OpenVEX 0.2.0 document at this path on a successful run; see "embedded VEX" below |
58+
| `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 |
5759
| `scan` | `--apply` / `--prune` / `--sync` || Mode selectors (sync = apply + prune) |
5860
| `scan` | `--batch-size` | `SOCKET_BATCH_SIZE` | API batch chunk size (default `100`) |
5961
| `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 |
@@ -73,6 +75,18 @@ Beyond the globals above, each subcommand defines a small set of local arguments
7375

7476
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.
7577

78+
### Embedded VEX (`apply --vex` / `scan --vex`)
79+
80+
`--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.
81+
82+
Contract details:
83+
84+
* **Always written to the file** — never stdout — so the document never races the command's own `--json` output.
85+
* **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`, …).
86+
* **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.
87+
* **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" }`.
88+
* `apply`'s no-manifest early exit (the "No .socket folder found" success no-op) does **not** trigger VEX generation — there is nothing to attest.
89+
7690
`repair` keeps its `gc` visible alias.
7791

7892
## Environment variables
@@ -105,6 +119,7 @@ All v3.0 env vars use the `SOCKET_*` prefix. Three legacy `SOCKET_PATCH_*` names
105119
| `SOCKET_ONE_OFF` | `get --one-off` / `rollback --one-off` | `false` | Local to `get`/`rollback`. |
106120
| `SOCKET_SKIP_ROLLBACK` | `remove --skip-rollback` | `false` | Local to `remove`. |
107121
| `SOCKET_DOWNLOAD_ONLY` | `repair --download-only` | `false` | Local to `repair`. |
122+
| `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. |
108123

109124
### Deprecated env vars
110125

crates/socket-patch-cli/src/commands/apply.rs

Lines changed: 64 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,8 +21,10 @@ use std::time::Duration;
2121
use tempfile::TempDir;
2222

2323
use crate::args::{apply_env_toggles, GlobalArgs};
24+
use crate::commands::vex::{generate_vex_from_manifest_path, VexEmbedArgs};
2425
use crate::json_envelope::{
2526
AppliedVia, Command, Envelope, EnvelopeError, PatchAction, PatchEvent, PatchEventFile, Status,
27+
VexSummary,
2628
};
2729

2830
/// Overlay every regular file from `src` into `dst` via hard link (falling
@@ -68,6 +70,13 @@ pub struct ApplyArgs {
6870
/// Skip pre-application hash verification (apply even if package version differs).
6971
#[arg(short = 'f', long, env = "SOCKET_FORCE", default_value_t = false)]
7072
pub force: bool,
73+
74+
/// On a successful apply, also generate an OpenVEX 0.2.0 document.
75+
/// `--vex <path>` is the trigger; the `--vex-*` knobs mirror the
76+
/// standalone `vex` command. A requested-but-failed VEX makes the
77+
/// whole command exit non-zero even when patches applied cleanly.
78+
#[command(flatten)]
79+
pub vex: VexEmbedArgs,
7180
}
7281

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

294+
// Embedded VEX: only on a successful apply and only when
295+
// `--vex <path>` was passed. Re-read the manifest fresh so
296+
// verification observes the just-applied on-disk state. The
297+
// result is folded into the JSON envelope / human output
298+
// below and flips the exit code on failure (per the
299+
// fail-the-command contract). `None` => not requested.
300+
let vex_result = if success && args.vex.vex.is_some() {
301+
let params = args.vex.to_build_params();
302+
Some(
303+
generate_vex_from_manifest_path(&args.common, &params, &manifest_path)
304+
.await,
305+
)
306+
} else {
307+
None
308+
};
309+
let vex_failed = matches!(vex_result, Some(Err(_)));
310+
285311
if args.common.json {
286312
let mut env = Envelope::new(Command::Apply);
287313
env.dry_run = args.common.dry_run;
@@ -312,6 +338,19 @@ pub async fn run(args: ApplyArgs) -> i32 {
312338
if !success {
313339
env.mark_partial_failure();
314340
}
341+
match &vex_result {
342+
Some(Ok(summary)) => {
343+
env.vex = Some(VexSummary {
344+
path: args.vex.vex.as_ref().unwrap().display().to_string(),
345+
statements: summary.statements,
346+
format: "openvex-0.2.0".to_string(),
347+
});
348+
}
349+
Some(Err(e)) => {
350+
env.mark_error(EnvelopeError::new(e.code, e.message.clone()));
351+
}
352+
None => {}
353+
}
315354
println!("{}", env.to_pretty_json());
316355
} else if !args.common.silent && !results.is_empty() {
317356
let patched: Vec<_> = results.iter().filter(|r| r.success).collect();
@@ -389,14 +428,38 @@ pub async fn run(args: ApplyArgs) -> i32 {
389428
}
390429
}
391430

431+
// Human-readable VEX status (JSON mode already folded the
432+
// outcome into the envelope above).
433+
if !args.common.json && !args.common.silent {
434+
match &vex_result {
435+
Some(Ok(summary)) => {
436+
println!(
437+
"Wrote OpenVEX document with {} statement(s) to {}",
438+
summary.statements,
439+
args.vex.vex.as_ref().unwrap().display(),
440+
);
441+
}
442+
Some(Err(e)) => {
443+
eprintln!("Error: VEX generation failed: {}", e.message);
444+
}
445+
None => {}
446+
}
447+
}
448+
392449
// Track telemetry
393450
if success {
394451
track_patch_applied(patched_count, args.common.dry_run, api_token.as_deref(), org_slug.as_deref()).await;
395452
} else {
396453
track_patch_apply_failed("One or more patches failed to apply", args.common.dry_run, api_token.as_deref(), org_slug.as_deref()).await;
397454
}
398455

399-
if success { 0 } else { 1 }
456+
// A requested-but-failed VEX flips an otherwise-successful
457+
// apply to a non-zero exit (fail-the-command contract).
458+
if success && !vex_failed {
459+
0
460+
} else {
461+
1
462+
}
400463
}
401464
Err(e) => {
402465
track_patch_apply_failed(&e, args.common.dry_run, api_token.as_deref(), org_slug.as_deref()).await;

crates/socket-patch-cli/src/commands/get.rs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -949,6 +949,9 @@ pub async fn download_and_apply_patches(
949949
..crate::args::GlobalArgs::default()
950950
},
951951
force: false,
952+
// get drives apply internally; embedded VEX is opt-in on the
953+
// top-level command, never on this internal invocation.
954+
vex: Default::default(),
952955
};
953956
let code = super::apply::run(apply_args).await;
954957
apply_succeeded = code == 0;
@@ -1530,6 +1533,9 @@ async fn save_and_apply_patch(
15301533
..crate::args::GlobalArgs::default()
15311534
},
15321535
force: false,
1536+
// get drives apply internally; embedded VEX is opt-in on the
1537+
// top-level command, never on this internal invocation.
1538+
vex: Default::default(),
15331539
};
15341540
let code = super::apply::run(apply_args).await;
15351541
apply_succeeded = code == 0;

0 commit comments

Comments
 (0)