Skip to content

Commit 59b98ab

Browse files
authored
Workflow enforcement: refuse invalid packages at commit, push, and set_remote (#753)
1 parent ee748b5 commit 59b98ab

30 files changed

Lines changed: 3881 additions & 131 deletions

Cargo.lock

Lines changed: 224 additions & 20 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,9 +58,11 @@ getrandom = "0.4"
5858
gitignores = "4.8"
5959
hex = "0.4.3"
6060
ignore = "0.4"
61+
jsonschema = { version = "0.47.0", default-features = false }
6162
multibase = "0.9.3"
6263
multihash = "0.19.5"
6364
percent-encoding = "2.3.2"
65+
regex = "1.12.4"
6466
reqwest = { version = "0.13.4", features = ["form", "json"] }
6567
reqwest-middleware = { version = "0.5.2", features = ["form", "json"] }
6668
reqwest-retry = "0.9.1"

docs/architecture.md

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -60,10 +60,12 @@ still addresses.
6060
The workspace splits along an I/O boundary:
6161

6262
- **WASM-safe leaf crates** (`quilt-uri` today): no I/O, compile to
63-
`wasm32-unknown-unknown`. We expect 1–2 more such extractions —
64-
likely candidates are checksum / hashing helpers and manifest
65-
types — but the bar is "clean API and reuse value", not a default
66-
path for every portable subset.
63+
`wasm32-unknown-unknown`. We expect a few more such extractions —
64+
likely candidates are checksum / hashing helpers, manifest types, and
65+
workflow validation (a pure `quilt-workflow`: the config model plus
66+
the rules-checking gate, no I/O — with reuse value for live
67+
client-side validation in the UI) — but the bar is "clean API and
68+
reuse value", not a default path for every portable subset.
6769
- **`quilt-rs`**: native-only library; depends on the leaf crates plus
6870
`aws-sdk-s3`, `tempfile`, `ignore`, `tokio`.
6971
- **Native consumers**: `quilt-cli`, `quilt-sync/src-tauri`.

quilt-cli/CHANGELOG.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,12 @@
99
<!-- markdownlint-disable MD013 -->
1010
# Changelog
1111

12+
## [v0.27.1-alpha4] - 2026-07-08
13+
14+
### Changed
15+
16+
- Committing or publishing a package that fails its bucket's workflow is now refused, naming the rule it violated, including when a first `quilt push` attaches the remote (<https://github.com/quiltdata/quilt-rs/pull/753>)
17+
1218
## [v0.27.1-alpha3] - 2026-07-08
1319

1420
### Changed

quilt-cli/Cargo.toml

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
name = "quilt-cli"
33
description = "Command-line interface for Quilt data packages"
44

5-
version = "0.27.1-alpha3"
5+
version = "0.27.1-alpha4"
66

77
# Inherit from workspace
88
edition.workspace = true
@@ -32,6 +32,10 @@ tracing.workspace = true
3232
tracing-subscriber.workspace = true
3333

3434
[dev-dependencies]
35+
# Pull quilt-rs with the `testing` feature so CLI tests can drive flows against
36+
# the in-memory mock remote. Kept out of the normal dependency above so the
37+
# mock never leaks into release builds.
38+
quilt-rs = { path = "../quilt-rs", version = "0.33.0-alpha1", features = ["testing"] }
3539
test-log.workspace = true
3640

3741
# pkg-url overridden: workspace tags as `quilt-cli/v<version>`, not

quilt-cli/src/cli/commit.rs

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -176,6 +176,55 @@ mod tests {
176176
Ok(())
177177
}
178178

179+
/// A commit that violates its workflow's `metadata_schema` must surface a
180+
/// clear CLI error. The `my-workflow` gate requires `Date`/`Name`/`Owner`/
181+
/// `Type` in the package metadata; committing with `Clear` (no metadata)
182+
/// against that same workflow is rejected, and the message the user sees
183+
/// must name the failing rule (`metadata_schema`) and every missing field.
184+
#[test(tokio::test)]
185+
async fn test_commit_rejected_by_workflow_surfaces_clear_error() -> Result<(), Error> {
186+
use crate::cli::fixtures::packages::my_workflow as pkg;
187+
188+
let uri = pkg::URI;
189+
let (m, _installed_package, _tempdir) = install_package_into_temp_dir(uri).await?;
190+
let local_domain = m.get_local_domain();
191+
192+
let err = model(
193+
local_domain,
194+
Input {
195+
message: pkg::MESSAGE.to_string(),
196+
namespace: pkg::NAMESPACE.into(),
197+
user_meta: UserMeta::Clear,
198+
workflow: WorkflowIntent::Named("my-workflow".to_string()),
199+
host_config: None,
200+
},
201+
)
202+
.await
203+
.unwrap_err();
204+
205+
let message = err.to_string();
206+
// The CLI wraps the quilt-rs error transparently, so the validator's own
207+
// wording reaches the user verbatim.
208+
assert!(
209+
message.starts_with("quilt_rs error: package does not satisfy the workflow"),
210+
"message must announce a workflow rejection, got: {message}"
211+
);
212+
// Names the failing rule …
213+
assert!(
214+
message.contains("metadata_schema"),
215+
"message must name the failing rule, got: {message}"
216+
);
217+
// … and every field the schema requires (order-independent).
218+
for field in ["Date", "Name", "Owner", "Type"] {
219+
assert!(
220+
message.contains(field),
221+
"message must name the missing field {field}, got: {message}"
222+
);
223+
}
224+
225+
Ok(())
226+
}
227+
179228
#[test(tokio::test)]
180229
async fn test_commit_package_with_workflow_and_meta() -> Result<(), Error> {
181230
use crate::cli::fixtures::packages::my_workflow as pkg;

quilt-cli/src/cli/push.rs

Lines changed: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -137,6 +137,157 @@ mod tests {
137137
Ok(())
138138
}
139139

140+
/// A push re-validates the revision against the destination bucket's
141+
/// **current** workflow config, not the version-pinned config the commit
142+
/// resolved against. A package committed valid under a permissive config is
143+
/// rejected once the bucket owner tightens that config — and the rejection
144+
/// surfaces at the CLI boundary with the validator's own wording, naming the
145+
/// failing rule (`metadata_schema`) and the missing field (`owner`).
146+
///
147+
/// This drives `InstalledPackage::push` (the call `push::model` makes)
148+
/// against an in-memory mock bucket, exposed by quilt-rs's `testing`
149+
/// feature — the only way to script the "config mutates between commit and
150+
/// push" scenario a live bucket cannot reproduce. It mirrors the commit-side
151+
/// `test_commit_rejected_by_workflow_surfaces_clear_error`.
152+
#[test(tokio::test)]
153+
async fn test_push_rejected_by_mutated_workflow_surfaces_clear_error() -> Result<(), Error> {
154+
use quilt_rs::InstalledPackage;
155+
use quilt_rs::io::remote::Remote;
156+
use quilt_rs::io::remote::mocks::MockRemote;
157+
use quilt_rs::lineage::DomainLineageIo;
158+
use quilt_rs::lineage::Home;
159+
use quilt_rs::lineage::PackageLineageIo;
160+
use quilt_rs::manifest::Workflow;
161+
use quilt_rs::manifest::WorkflowId;
162+
use quilt_rs::paths::DomainPaths;
163+
use quilt_uri::S3Uri;
164+
use tempfile::TempDir;
165+
166+
let home_dir = TempDir::new()?;
167+
let paths_dir = TempDir::new()?;
168+
let storage = LocalStorage::new();
169+
let remote = MockRemote::default();
170+
let namespace: Namespace = ("reference", "push-gate").into();
171+
let home = Home::new(home_dir.path().to_path_buf());
172+
let paths = DomainPaths::new(paths_dir.path().to_path_buf());
173+
174+
paths
175+
.scaffold_for_installing(&storage, &home, &namespace)
176+
.await?;
177+
178+
// A brand-new local package: a commit will be stamped below, and only
179+
// then is the remote attached, mirroring `create → commit → set-remote`.
180+
let lineage_json = format!(
181+
r#"{{
182+
"packages": {{
183+
"reference/push-gate": {{
184+
"commit": null,
185+
"remote": null,
186+
"base_hash": "",
187+
"latest_hash": "",
188+
"paths": {{}}
189+
}}
190+
}},
191+
"home": "{}"
192+
}}"#,
193+
home_dir.path().display()
194+
);
195+
storage
196+
.write_byte_stream(&paths.lineage(), lineage_json.as_bytes().to_vec().into())
197+
.await?;
198+
199+
let config_uri = S3Uri::try_from("s3://b/.quilt/workflows/config.yml")?;
200+
// Config v1: a permissive `gate` workflow with no metadata_schema.
201+
remote
202+
.put_object(
203+
&None,
204+
&config_uri,
205+
b"version: \"1\"\nworkflows:\n gate:\n name: Gate\n".to_vec(),
206+
)
207+
.await?;
208+
209+
let package = InstalledPackage {
210+
lineage: PackageLineageIo::new(
211+
DomainLineageIo::new(paths.lineage()),
212+
namespace.clone(),
213+
),
214+
paths,
215+
remote,
216+
storage,
217+
namespace: namespace.clone(),
218+
};
219+
220+
// Commit valid under v1: the `gate` workflow (which declares no
221+
// metadata_schema) is stamped into the header, so clearing the
222+
// package metadata passes the commit-time gate.
223+
let workflow = Workflow {
224+
config: config_uri.to_string().parse()?,
225+
id: Some(WorkflowId {
226+
id: "gate".to_string(),
227+
metadata: None,
228+
}),
229+
};
230+
package
231+
.commit(
232+
"governed commit".to_string(),
233+
UserMeta::Clear,
234+
Some(workflow),
235+
None,
236+
)
237+
.await?;
238+
239+
// Attach the governed bucket `b` now, first-push (no remote hash yet),
240+
// so push skips the remote-manifest browse and reaches the gate.
241+
package
242+
.set_remote("b".to_string(), None, WorkflowIntent::BucketDefault)
243+
.await?;
244+
245+
// The bucket owner tightens the *current* config: `gate` now requires an
246+
// `owner` key the committed (cleared) metadata lacks. Removing this
247+
// mutation is the discriminating (RED-equivalent) run: the push then
248+
// clears the gate and succeeds, and the rejection assertions fail.
249+
package
250+
.remote
251+
.put_object(
252+
&None,
253+
&config_uri,
254+
b"version: \"1\"\nworkflows:\n gate:\n name: Gate\n metadata_schema: meta\nschemas:\n meta:\n url: s3://b/schemas/meta.json\n".to_vec(),
255+
)
256+
.await?;
257+
package
258+
.remote
259+
.put_object(
260+
&None,
261+
&S3Uri::try_from("s3://b/schemas/meta.json")?,
262+
br#"{"type": "object", "required": ["owner"]}"#.to_vec(),
263+
)
264+
.await?;
265+
266+
// Push re-resolves the current config and rejects the identical
267+
// manifest. The CLI wraps the quilt-rs error transparently, so the
268+
// validator's own wording reaches the user verbatim.
269+
let Err(err) = package.push(None).await else {
270+
return Err(Error::Test(
271+
"push must be rejected by the tightened workflow config".to_string(),
272+
));
273+
};
274+
let message = Error::from(err).to_string();
275+
assert!(
276+
message.starts_with("quilt_rs error: package does not satisfy the workflow"),
277+
"message must announce a workflow rejection, got: {message}"
278+
);
279+
assert!(
280+
message.contains("metadata_schema"),
281+
"message must name the failing rule, got: {message}"
282+
);
283+
assert!(
284+
message.contains("owner"),
285+
"message must name the missing field, got: {message}"
286+
);
287+
288+
Ok(())
289+
}
290+
140291
/// Verifies that push command returns error when there are no commits:
141292
/// * installs a package but makes no commits
142293
/// * attempts to push without commits

quilt-rs/CHANGELOG.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,13 @@
99
<!-- markdownlint-disable MD013 -->
1010
# Changelog
1111

12+
## [v0.33.0-alpha5] - 2026-07-08
13+
14+
### Added
15+
16+
- A candidate package is now validated against its workflow (metadata and entries schemas, handle pattern, message-required, workflow-required) — the resolved workflow at commit and `set_remote`, the destination bucket's current workflow configuration at push — and an invalid package is refused with the failing rule while its previous state is left untouched (<https://github.com/quiltdata/quilt-rs/pull/753>)
17+
- A `.quilt/workflows/config.yml` is now validated against quilt3's config schema when loaded, so a malformed config (e.g. a mistyped field) is refused everywhere instead of silently disabling the rule the bucket owner expected to enforce (<https://github.com/quiltdata/quilt-rs/pull/753>)
18+
1219
## [v0.33.0-alpha4] - 2026-07-08
1320

1421
### Changed

quilt-rs/Cargo.toml

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,13 +3,18 @@ name = "quilt-rs"
33
description = "Rust library for accessing Quilt data packages."
44

55
# Inherit from workspace
6-
version = "0.33.0-alpha4"
6+
version = "0.33.0-alpha5"
77
edition.workspace = true
88
rust-version.workspace = true
99
license.workspace = true
1010
repository.workspace = true
1111
homepage.workspace = true
1212

13+
[features]
14+
# Exposes the in-memory mock `Remote`/`Storage` implementations so downstream
15+
# crates can drive quilt-rs flows against a fake bucket in their own tests.
16+
testing = []
17+
1318
[dependencies]
1419
async-trait.workspace = true
1520
base64.workspace = true
@@ -24,9 +29,11 @@ getrandom.workspace = true
2429
gitignores.workspace = true
2530
hex.workspace = true
2631
ignore.workspace = true
32+
jsonschema.workspace = true
2733
multibase.workspace = true
2834
multihash.workspace = true
2935
percent-encoding.workspace = true
36+
regex.workspace = true
3037
reqwest.workspace = true
3138
reqwest-middleware.workspace = true
3239
reqwest-retry.workspace = true

quilt-rs/src/error.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ use reqwest::header::ToStrError;
77
use thiserror::Error;
88

99
use crate::io::remote::HostChecksums;
10+
use crate::workflow::WorkflowValidationError;
1011
use quilt_uri::Host;
1112
use quilt_uri::Namespace;
1213
use quilt_uri::UriError;
@@ -306,6 +307,9 @@ pub enum Error {
306307
#[error("UTF-8 error: {0}")]
307308
Utf8(#[from] Utf8Error),
308309

310+
#[error(transparent)]
311+
WorkflowValidation(#[from] WorkflowValidationError),
312+
309313
#[error("YAML error: {0}")]
310314
Yaml(#[from] serde_yaml::Error),
311315
}

0 commit comments

Comments
 (0)