Skip to content

Commit 9cd84a6

Browse files
feat: drive cross-compiled matrix packages with matrix/build subcommands
Reconcile the three naming systems for a matrix binary (Rust triple, CI runner, Node process.platform-process.arch stage dir) in a built-in target registry, so the tool — not hand-edited YAML — owns the mapping that decides whether an install can find the published binary. - config: enrich Target with triple/runner/stage_as/ext/cross (registry-filled from name/arch) and add per-package bin_name/compress fields. - add `otf-release matrix` (emit the GH matrix JSON from release.toml) and `otf-release build --package --target` (cross-compile one target, then brotli- stage it at bin/<stage_as>/<bin><ext>[.br]). - init: regenerate the matrix workflow as a dynamic matrix-<pkg> -> build-<pkg> -> publish DAG that calls the new subcommands; no more `# edit me` target list or untemplated build command. Publish merges each target's artifact back into .artifacts/<pkg> before packing. - publish: refuse a matrix package whose per-platform binaries were not staged, replacing the removed private:true guard. - npm: publish prereleases under their dist-tag (1.2.3-dev.x -> --tag dev); unify the auth secret to NPM_TOKEN across the release and snapshot workflows. - docs + CHANGELOG updated for the new flow.
1 parent 40f86b5 commit 9cd84a6

20 files changed

Lines changed: 1282 additions & 266 deletions

File tree

CHANGELOG.md

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,14 +8,34 @@ adheres to [Semantic Versioning](https://semver.org/). Work in progress lives un
88

99
## [Unreleased]
1010

11+
### Added
12+
- **matrix/build commands** — Added `otf-release matrix` (emits the GitHub Actions build matrix
13+
from `release.toml`) and `otf-release build --package --target` (cross-compiles one target and
14+
stages its binary). The generated matrix workflow now drives both, so cross-compiled binary
15+
packages build and publish with no hand-edited YAML.
16+
- **target registry**`[[package.targets]]` now reconciles the Rust triple, the CI runner, and
17+
the Node `process.platform-process.arch` stage directory (`stage_as`), plus `ext`/`cross`. A
18+
hand-written file may list just `name`/`arch`; the built-in registry fills the rest. Added
19+
per-package `bin_name` and `compress` (brotli) fields.
20+
1121
### Changed
22+
- **init** — The matrix workflow is regenerated as a dynamic `matrix-<pkg>``build-<pkg>`
23+
`publish` DAG that calls `otf-release matrix`/`build`, removing the `# edit me` target list and
24+
the untemplated build command. Staged binaries land at `bin/<platform>-<arch>/<bin>[.br]`, the
25+
exact path an npm package's install-time resolver reads.
1226
- **init** — Removed snapshot tag prompting and `snapshot.yml` generation from the setup flow;
1327
snapshot releases remain available through the dedicated `snapshot` command.
1428
- **changelog config** — Added `changelog_scope` with strict root-level or per-package changelog
1529
modes, updated `init` to ask only where release notes are maintained, and made package-scope
1630
GitHub Release bodies combine notes from all configured package changelogs.
1731

1832
### Fixed
33+
- **publish** — A `matrix` publish-mode package is now refused if its per-platform binaries were
34+
not staged under `--artifacts-dir`, replacing the removed `private:true` guard so a binary-less
35+
package can never reach the registry.
36+
- **npm adapter** — A prerelease version publishes under its own dist-tag (`1.2.3-dev.<hash>`
37+
`--tag dev`) instead of `latest`.
38+
- **init** — Unified the npm auth secret to `NPM_TOKEN` across the release and snapshot workflows.
1939
- **publish** — Made tag creation and GitHub Release creation idempotent so interrupted publish
2040
runs can be resumed without failing on already-created remote state.
2141
- **cargo adapter** — Treated missing `cargo info` package results as unpublished and aligned the

Cargo.lock

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

README.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,9 @@ cargo install --git https://github.com/Open-Tech-Foundation/release
3535
| --- | --- | --- |
3636
| `otf-release init` | ✅ Supported | Interactive setup. Writes `release.toml` and `.github/workflows/release.yml`. |
3737
| `otf-release version` | ✅ Supported | Interactive local release flow. Use `--dry-run` to preview the plan without writing files, and `--first-release` when a package has no prior matching tag. |
38-
| `otf-release publish` | ✅ Supported | CI-oriented publish flow. Publishes in dependency order, skips already-published versions, creates `name@version` tags, and creates package releases from notes. |
38+
| `otf-release publish` | ✅ Supported | CI-oriented publish flow. Publishes in dependency order, skips already-published versions, creates `name@version` tags, and creates package releases from notes. Refuses to publish a matrix package whose per-platform binaries weren't staged. |
39+
| `otf-release matrix` | ✅ Supported | CI helper. Prints the GitHub Actions build matrix (JSON) for a matrix package from `release.toml`, so `release.yml` never carries a hand-maintained target list. |
40+
| `otf-release build` | ✅ Supported | CI helper. Builds one matrix target (`--package`/`--target`), cross-compiling as needed, and stages the binary at `bin/<platform>-<arch>/<bin>[.br]` for publish. |
3941
| `otf-release snapshot` | 🧪 Experimental | Creates hash-based prerelease versions such as `1.2.3-snapshot.a1b2c3d` and publishes them from CI. |
4042
| `otf-release config` | ✅ Supported | Interactive editor for hooks, ecosystems, package build fields, generic package fields, provider, snapshot tag, changelog scope/strategy, and GitHub Release notes. |
4143
| `otf-release upgrade` | ◐ Partial | Regenerates `release.yml` from the current `release.toml`. |

crates/adapters/src/npm/mod.rs

Lines changed: 47 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -248,18 +248,30 @@ impl Adapter for NpmAdapter {
248248
.with_context(|| format!("staging assets for {}", pkg.name))?;
249249
}
250250

251-
let out = self.runner.run(
252-
"npm",
253-
&["publish", "--access", "public", "--no-workspaces"],
254-
pkg_dir,
255-
)?;
251+
// A prerelease version (e.g. a `1.2.3-dev.<hash>` snapshot) must publish under its own
252+
// dist-tag, never `latest`, so an automated snapshot never becomes the default install.
253+
let mut args = vec!["publish", "--access", "public", "--no-workspaces"];
254+
let tag = dist_tag(&pkg.version);
255+
if let Some(tag) = &tag {
256+
args.push("--tag");
257+
args.push(tag);
258+
}
259+
let out = self.runner.run("npm", &args, pkg_dir)?;
256260
if !out.success {
257261
bail!("`npm publish` for {} failed:\n{}", pkg.name, out.stderr);
258262
}
259263
Ok(())
260264
}
261265
}
262266

267+
/// The npm dist-tag for a version: a prerelease's leading identifier (`1.2.3-dev.abc` → `dev`,
268+
/// `2.0.0-beta.1` → `beta`), or `None` for a normal release (which publishes under `latest`).
269+
fn dist_tag(version: &str) -> Option<String> {
270+
let pre = version.split_once('-')?.1;
271+
let id = pre.split('.').next().unwrap_or(pre);
272+
(!id.is_empty()).then(|| id.to_string())
273+
}
274+
263275
fn skip_reason(manifest: &Manifest) -> Result<Option<String>> {
264276
let json = manifest.json_value()?;
265277
let missing_name = json.get("name").and_then(Value::as_str).is_none();
@@ -598,6 +610,36 @@ mod tests {
598610
assert_eq!(calls[0].2, PathBuf::from("/repo/packages/a"));
599611
}
600612

613+
#[test]
614+
fn prerelease_publishes_under_its_dist_tag() {
615+
let fake = FakeRunner::new(true, "", "");
616+
let adapter = NpmAdapter::with_runner("/repo", Box::new(fake.clone()));
617+
let mut pkg = dummy_pkg("@x/a", "/repo/packages/a/package.json");
618+
pkg.version = "1.2.3-dev.abc1234".to_string();
619+
620+
adapter.publish(&pkg, None).unwrap();
621+
let calls = fake.calls.lock().unwrap();
622+
assert_eq!(
623+
calls[0].1,
624+
[
625+
"publish",
626+
"--access",
627+
"public",
628+
"--no-workspaces",
629+
"--tag",
630+
"dev"
631+
]
632+
);
633+
}
634+
635+
#[test]
636+
fn dist_tag_only_for_prereleases() {
637+
assert_eq!(dist_tag("1.2.3"), None);
638+
assert_eq!(dist_tag("1.2.3-dev.abc"), Some("dev".to_string()));
639+
assert_eq!(dist_tag("2.0.0-beta.1"), Some("beta".to_string()));
640+
assert_eq!(dist_tag("2.0.0-rc"), Some("rc".to_string()));
641+
}
642+
601643
#[test]
602644
fn reformat_range_preserves_operator() {
603645
assert_eq!(reformat_range("^1.0.0", "2.0.0"), "^2.0.0");

crates/cli/src/main.rs

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -128,6 +128,21 @@ enum Command {
128128
},
129129
/// Edit release.toml interactively.
130130
Config,
131+
/// CI: print the GitHub Actions build matrix (JSON) for a matrix package from release.toml.
132+
Matrix {
133+
/// Which matrix package to emit for (required when more than one exists).
134+
#[arg(long)]
135+
package: Option<String>,
136+
},
137+
/// CI: build one matrix target and stage its binary under `.artifacts/`.
138+
Build {
139+
/// The matrix package to build.
140+
#[arg(long)]
141+
package: String,
142+
/// The target as `name/arch` (e.g. linux/aarch64).
143+
#[arg(long)]
144+
target: String,
145+
},
131146
/// Non-interactive, CI: automated ephemeral release via short git hashes.
132147
Snapshot,
133148
/// Update otf-release to the latest version.
@@ -177,6 +192,19 @@ fn run() -> Result<()> {
177192
otf_release_core::config_cmd::orchestrate(&root)?;
178193
Ok(())
179194
}
195+
Command::Matrix { package } => {
196+
let config = ReleaseConfig::load(&root)?;
197+
println!(
198+
"{}",
199+
otf_release_core::matrix::matrix_json(&config, package.as_deref())?
200+
);
201+
Ok(())
202+
}
203+
Command::Build { package, target } => {
204+
let config = ReleaseConfig::load(&root)?;
205+
otf_release_core::build::run(&config, &root, &package, &target)?;
206+
Ok(())
207+
}
180208
Command::Snapshot => {
181209
let config = ReleaseConfig::load(&root)?;
182210
let factory = CliAdapterFactory {
@@ -232,6 +260,8 @@ fn run() -> Result<()> {
232260
// build-only packages ship via the GitHub Release the workflow creates, never a
233261
// registry — so `publish` skips them.
234262
let skip = config.build_only_names();
263+
// matrix packages must have their per-platform binaries staged before they publish.
264+
let require_staged = config.matrix_publish_names();
235265
let adapters: Vec<Box<dyn Adapter>> = config
236266
.adapters
237267
.iter()
@@ -247,6 +277,7 @@ fn run() -> Result<()> {
247277
dry_run,
248278
tag_format: config.tag_format.clone(),
249279
skip,
280+
require_staged,
250281
changelog_scope: config.changelog_scope.clone(),
251282
},
252283
&config.hooks,

crates/cli/tests/publish_flow.rs

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -452,3 +452,51 @@ fn multi_adapter_publish_runs_hooks_once_for_the_whole_command() {
452452
assert_eq!(a.published.borrow().as_slice(), ["a@1.0.0".to_string()]);
453453
assert_eq!(b.published.borrow().as_slice(), ["b@1.0.0".to_string()]);
454454
}
455+
456+
/// A matrix publish-mode package must never reach the registry without its per-platform binaries.
457+
/// With the package named in `require_staged` and no staged tree under `--artifacts-dir`, publish
458+
/// must hard-fail instead of shipping a binary-less, broken package.
459+
#[test]
460+
fn matrix_package_without_staged_binaries_is_refused() {
461+
let tmp = tempfile::tempdir().unwrap();
462+
let root = tmp.path();
463+
write(
464+
root.join("package.json"),
465+
r#"{ "name": "root", "private": true, "workspaces": ["packages/*"] }"#,
466+
);
467+
write(
468+
root.join("packages/wc/package.json"),
469+
"{\n \"name\": \"@x/wc\",\n \"version\": \"1.0.0\"\n}\n",
470+
);
471+
write(
472+
root.join("packages/wc/CHANGELOG.md"),
473+
"# Changelog\n\n## [1.0.0] - 2024-01-01\n- notes\n",
474+
);
475+
476+
let runner = PubRunner::new(&[]);
477+
let adapter = NpmAdapter::with_runner(root, Box::new(runner.clone()));
478+
let git = FakeGit::default();
479+
let forge = FakeForge::default();
480+
let hooks = otf_release_core::config::Hooks::default();
481+
let hook_runner = otf_release_core::hooks::fakes::FakeHookRunner::new();
482+
483+
// An artifacts dir that exists but holds nothing for @x/wc.
484+
let artifacts = root.join(".artifacts");
485+
fs::create_dir_all(&artifacts).unwrap();
486+
487+
let opts = PublishOptions {
488+
tag_format: "{name}@{version}".to_string(),
489+
artifacts_dir: Some(artifacts),
490+
require_staged: vec!["@x/wc".to_string()],
491+
..PublishOptions::default()
492+
};
493+
494+
let err = orchestrate(&adapter, &git, &forge, root, &opts, &hooks, &hook_runner).unwrap_err();
495+
assert!(
496+
err.to_string().contains("binary-less"),
497+
"expected a binary-less refusal, got: {err}"
498+
);
499+
// Nothing was published, tagged, or released.
500+
assert!(runner.publish_log.lock().unwrap().is_empty());
501+
assert!(git.tags.borrow().is_empty());
502+
}

crates/core/Cargo.toml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,8 @@ serde.workspace = true
1515
toml.workspace = true
1616
inquire.workspace = true
1717
ratatui.workspace = true
18+
brotli = "7"
19+
glob = "0.3"
1820

1921
[dev-dependencies]
2022
tempfile = "3"

0 commit comments

Comments
 (0)