Skip to content

Commit 7c4e86d

Browse files
authored
Filter publish output by registry version (cargo info) (#21)
* feat(publish): emit only crates whose local version is newer than the registry Previously the `publish` output contained every crate with `publish != false`, even when the local Cargo.toml version matched what was already on the registry — so a `cargo publish -p ${{ matrix.package }}` step would fail with "crate version already uploaded" until a human pruned the matrix. `parseMetadata` now returns `publishCandidates: [{name, version, registry}]` and a new async `filterPublishable` runs `cargo info <name>` (with `--registry` when the candidate is registry-restricted) for each candidate, keeping only those whose local version is strictly greater per a full semver §11 compare. Crates absent from the registry are kept (first publish); other `cargo info` failures log a warning and skip the candidate, so a transient outage cannot republish a stale crate. Tests: rewrote publish-related cases for the new shape and added unit tests for `compareSemver` (prerelease precedence, numeric vs lexical, build metadata) and `parseCargoInfoVersion`. All 28 tests pass; dist rebuilt. Assisted-By: Claude <noreply@anthropic.com> Signed-off-by: Sergey Vilgelm <sergey@vilgelm.com> * chore: bump to 1.1.0 New `publish` output filter (only crates with a newer-than-registry version) is a user-visible feature addition; bump minor. Assisted-By: Claude <noreply@anthropic.com> Signed-off-by: Sergey Vilgelm <sergey@vilgelm.com> * fix(publish): force registry lookup in cargo info, rename colliding fixture Two related Fixtures-CI bugs surfaced once the publish output started using cargo info: 1. `cargo info <name>` (no `--registry`) probes the workspace at `cwd` first and returns the *local* member's version when the name matches. That made `local == published` for every workspace candidate and silently emptied the publish output. Always pass `--registry` (default `crates-io`) so cargo skips the local lookup. 2. The workspace-mixed fixture's `app` package collided with the real `app` crate on crates.io (already at 0.6.5), so the fixture's 0.1.0 was correctly classified as stale and excluded — but the workflow asserted the opposite. Rename the fixture to `rma-fixture-app` (with a comment explaining why) so it's guaranteed to be a first-publish, and update the workspace-mixed assertions accordingly. Assisted-By: Claude <noreply@anthropic.com> Signed-off-by: Sergey Vilgelm <sergey@vilgelm.com> * review: address Copilot review comments on filter logic Three issues called out by the PR-21 review: 1. Multi-registry candidates (`publish = ["a","b"]`) only had their first entry queried, so a crate stale on registry "b" but absent from "a" could still appear in the publish list. parseMetadata now emits the full `registries: string[]` (with `[null]` for unrestricted), and the new `getMaxPublishedVersion` queries every entry and compares against the highest version found. 2. `getPublishedVersion` resolved to `null` when `cargo info` exited 0 but the parser couldn't find a `version:` line — `filterPublishable` then treated that as "first publish" and included the crate, which would mass-republish on any future cargo output drift. Reject in that path instead, so the catch path warns and skips (safe default). 3. `Promise.all` over every candidate spawned one `cargo info` per crate concurrently. Replaced with a small `asyncPool` (limit 4) so resource use stays predictable on large workspaces and we don't trip registry rate limits. Tests: updated publish-candidate fixtures to assert `registries: [...]`, added a multi-registry test case, plus three asyncPool tests (order, concurrency cap, empty input). 32/32 pass; dist rebuilt. Assisted-By: Claude <noreply@anthropic.com> Signed-off-by: Sergey Vilgelm <sergey@vilgelm.com> * review: drop unused index argument from asyncPool worker call `asyncPool`'s JSDoc already documented the worker as `worker(item)`, but the call site was `worker(items[i], i)` — passing an index that no caller consumes. Flagged by github-code-quality[bot] on PR 21. Drop the second arg so code matches the documented signature; trivially re-addable if a future caller actually wants the index. Assisted-By: Claude <noreply@anthropic.com> Signed-off-by: Sergey Vilgelm <sergey@vilgelm.com> --------- Signed-off-by: Sergey Vilgelm <sergey@vilgelm.com>
1 parent f3e4c81 commit 7c4e86d

9 files changed

Lines changed: 605 additions & 41 deletions

File tree

.github/workflows/checks.yml

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -140,11 +140,11 @@ jobs:
140140
# workspace member declaration order, but feature order is alphabetical.
141141
# Sorting both sides keeps the assertion stable across cargo versions.
142142
run: |
143-
jq -ne --argjson got "$PACKAGES" '$got | sort == ["app","feature-lib","private-lib"]' \
143+
jq -ne --argjson got "$PACKAGES" '$got | sort == ["feature-lib","private-lib","rma-fixture-app"]' \
144144
|| { echo "packages: $PACKAGES"; exit 1; }
145-
jq -ne --argjson got "$PUBLISH" '$got | sort == ["app","feature-lib"]' \
145+
jq -ne --argjson got "$PUBLISH" '$got | sort == ["feature-lib","rma-fixture-app"]' \
146146
|| { echo "publish: $PUBLISH"; exit 1; }
147-
jq -ne --argjson got "$MATRIX" '$got | sort == ["--package=app","--package=feature-lib --features=bar","--package=feature-lib --features=default","--package=feature-lib --features=foo","--package=private-lib"]' \
147+
jq -ne --argjson got "$MATRIX" '$got | sort == ["--package=feature-lib --features=bar","--package=feature-lib --features=default","--package=feature-lib --features=foo","--package=private-lib","--package=rma-fixture-app"]' \
148148
|| { echo "matrix: $MATRIX"; exit 1; }
149149
[ "$RUST_VERSION" = '1.90' ] || { echo "rust-version: $RUST_VERSION"; exit 1; }
150150
[ "$EDITION" = '2024' ] || { echo "edition: $EDITION"; exit 1; }

README.md

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ matrix).
1818

1919
- `metadata`: Raw cargo metadata JSON.
2020
- `packages`: JSON array (string) of package names, e.g. `["foo","bar"]`.
21-
- `publish`: JSON array (string) of packages that can be published.
21+
- `publish`: JSON array (string) of packages whose local `version` is strictly newer than the version on the registry — i.e. the set that actually needs to be published. The action runs `cargo info` for each publishable candidate and compares versions per semver. Packages declared `publish = false` are always excluded; packages not yet on the registry are included (first publish); packages restricted to a private registry via `publish = ["my-registry"]` are queried against that registry.
2222
- `matrix`: JSON array (string) of command-line fragments suitable for use as a job matrix, e.g.
2323
`["--package=foo","--package=bar --features=foo"]`
2424
- `rust-version`: Workspace MSRV — the highest `rust-version` declared by any package, compared numerically (so `1.10`
@@ -112,6 +112,10 @@ jobs:
112112
additional bare `--package=<name>` row in this case. The intent is to drive `cargo {test,build}` per feature
113113
rather than to also test "no features".
114114
- `publish = false` packages still appear in `packages` but are excluded from `publish`.
115+
- `publish` filtering:
116+
- Each publishable candidate is checked with `cargo info`. Only packages whose Cargo.toml `version` is strictly greater than the latest on the registry are emitted.
117+
- Crates not yet on the registry (cargo info reports "could not find …") are included so a first-time publish goes through.
118+
- If `cargo info` fails for any other reason (network, registry outage), that candidate is logged as a warning and skipped — the action errs on the side of *not* republishing rather than spuriously emitting a stale package.
115119

116120
## License
117121

action.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ outputs:
1414
packages:
1515
description: 'JSON-encoded array of every package name in the workspace. Example: `["foo","bar"]`.'
1616
publish:
17-
description: 'JSON-encoded array of package names that are publishable. Packages declared `publish = false` are excluded; packages restricted to specific registries are included. Example: `["foo","bar"]`.'
17+
description: 'JSON-encoded array of package names that need to be published — i.e. their local `version` is strictly newer than what is on the registry. The action runs `cargo info` for each publishable candidate and compares versions per semver. Packages declared `publish = false` are always excluded; packages not yet on the registry are included (first publish); packages whose registry version is equal or newer are skipped. Packages restricted to a specific registry via `publish = ["my-registry"]` are queried against that registry. Example: `["foo","bar"]`.'
1818
matrix:
1919
description: 'JSON-encoded array of cargo argument strings — one entry per package, or one per feature for packages that declare features. Intended for `strategy.matrix`. Example: `["--package=foo","--package=bar --features=full"]`.'
2020
rust-version:

dist/index.js

Lines changed: 216 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -27976,7 +27976,7 @@ __webpack_async_result__();
2797627976
/* harmony export */ __nccwpck_require__.d(__webpack_exports__, {
2797727977
/* harmony export */ eF: () => (/* binding */ run)
2797827978
/* harmony export */ });
27979-
/* unused harmony exports ensureToolchain, runCargoMetadata, parseMetadata, writeOutputs */
27979+
/* unused harmony exports ensureToolchain, runCargoMetadata, compareSemver, parseCargoInfoVersion, getPublishedVersion, getMaxPublishedVersion, asyncPool, parseMetadata, filterPublishable, writeOutputs */
2798027980
/* harmony import */ var _actions_core__WEBPACK_IMPORTED_MODULE_0__ = __nccwpck_require__(2698);
2798127981
/* harmony import */ var child_process__WEBPACK_IMPORTED_MODULE_1__ = __nccwpck_require__(5317);
2798227982
/* harmony import */ var path__WEBPACK_IMPORTED_MODULE_2__ = __nccwpck_require__(6928);
@@ -28086,16 +28086,183 @@ function compareVersion(a, b) {
2808628086
return 0;
2808728087
}
2808828088

28089+
// Full semver compare per https://semver.org §11. Returns negative if a < b,
28090+
// 0 if equal in precedence, positive if a > b. Build metadata (`+...`) is
28091+
// ignored; prerelease segments (`-...`) are compared per spec: numeric < non-
28092+
// numeric, and a version with a prerelease is lower than the same without.
28093+
function compareSemver(a, b) {
28094+
const stripBuild = (v) => v.split("+")[0];
28095+
const split = (v) => {
28096+
const s = stripBuild(v);
28097+
const i = s.indexOf("-");
28098+
return i === -1
28099+
? { main: s, pre: null }
28100+
: { main: s.slice(0, i), pre: s.slice(i + 1) };
28101+
};
28102+
const sa = split(a);
28103+
const sb = split(b);
28104+
const parse = (m) => m.split(".").map((p) => parseInt(p, 10) || 0);
28105+
const av = parse(sa.main);
28106+
const bv = parse(sb.main);
28107+
for (let i = 0; i < 3; i++) {
28108+
const diff = (av[i] ?? 0) - (bv[i] ?? 0);
28109+
if (diff !== 0) return diff;
28110+
}
28111+
if (sa.pre === null && sb.pre === null) return 0;
28112+
if (sa.pre === null) return 1;
28113+
if (sb.pre === null) return -1;
28114+
const ap = sa.pre.split(".");
28115+
const bp = sb.pre.split(".");
28116+
for (let i = 0; i < Math.max(ap.length, bp.length); i++) {
28117+
if (ap[i] === undefined) return -1;
28118+
if (bp[i] === undefined) return 1;
28119+
const aNum = /^\d+$/.test(ap[i]);
28120+
const bNum = /^\d+$/.test(bp[i]);
28121+
if (aNum && bNum) {
28122+
const d = parseInt(ap[i], 10) - parseInt(bp[i], 10);
28123+
if (d !== 0) return d;
28124+
} else if (aNum !== bNum) {
28125+
// Numeric identifiers always have lower precedence than alphanumeric.
28126+
return aNum ? -1 : 1;
28127+
} else {
28128+
const c = ap[i] < bp[i] ? -1 : ap[i] > bp[i] ? 1 : 0;
28129+
if (c !== 0) return c;
28130+
}
28131+
}
28132+
return 0;
28133+
}
28134+
28135+
// Pulls the published version from cargo info's stdout.
28136+
function parseCargoInfoVersion(stdout) {
28137+
const m = stdout.match(/^version:\s*(\S+)/im);
28138+
return m ? m[1] : null;
28139+
}
28140+
28141+
// Run `cargo info <name>` and return the latest published version, or null
28142+
// if the package isn't on the registry yet (first publish). Other failures
28143+
// reject so callers can surface them.
28144+
//
28145+
// `--registry` is *always* passed, defaulting to the built-in `crates-io`
28146+
// alias. Without it, `cargo info` first probes the workspace at `cwd` and
28147+
// returns the *local* crate's version when the name happens to match a
28148+
// workspace member — making `local == published` for every candidate and
28149+
// silently emptying the publish output. With `--registry`, cargo skips the
28150+
// local lookup and queries the named registry directly.
28151+
//
28152+
// A successful exit with no parseable `version:` line is treated as an
28153+
// error, *not* as "first publish" — if cargo's output format ever drifts,
28154+
// the caller's catch path will skip the candidate (safe default) instead
28155+
// of mass-republishing every crate it can't parse.
28156+
function getPublishedVersion(pkgName, cwd, registry) {
28157+
return new Promise((resolve, reject) => {
28158+
const args = ["info", pkgName, "--registry", registry || "crates-io"];
28159+
const cmd = (0,child_process__WEBPACK_IMPORTED_MODULE_1__.spawn)("cargo", args, { cwd });
28160+
28161+
const stdoutChunks = [];
28162+
const stderrChunks = [];
28163+
cmd.stdout.on("data", (chunk) => stdoutChunks.push(chunk));
28164+
cmd.stderr.on("data", (chunk) => stderrChunks.push(chunk));
28165+
28166+
cmd.on("error", (error) => {
28167+
reject(new Error(`cargo info failed to spawn: ${error.message}`));
28168+
});
28169+
28170+
cmd.on("close", (code) => {
28171+
const stdout = Buffer.concat(stdoutChunks).toString();
28172+
const stderr = Buffer.concat(stderrChunks).toString();
28173+
if (code !== 0) {
28174+
if (/could not find/i.test(stderr)) {
28175+
resolve(null);
28176+
return;
28177+
}
28178+
reject(
28179+
new Error(
28180+
`cargo info ${pkgName} failed (exit code ${code})` +
28181+
(stderr.trim() ? `: ${stderr.trim()}` : ""),
28182+
),
28183+
);
28184+
return;
28185+
}
28186+
const parsed = parseCargoInfoVersion(stdout);
28187+
if (parsed === null) {
28188+
reject(
28189+
new Error(
28190+
`cargo info ${pkgName} returned no parseable \`version:\` line`,
28191+
),
28192+
);
28193+
return;
28194+
}
28195+
resolve(parsed);
28196+
});
28197+
});
28198+
}
28199+
28200+
// Query each registry in `registries` for the package's latest version and
28201+
// return the highest one found (or null if no registry has the crate).
28202+
//
28203+
// `pkg.publish` in Cargo.toml may list multiple registries — the crate is
28204+
// then publishable to any of them. We can't predict which the user will
28205+
// `cargo publish --registry <X>` against, so the conservative answer is
28206+
// "is it newer than every place it could land?". Taking the max and
28207+
// requiring `local > max` prevents emitting a crate that would hit
28208+
// "version already uploaded" on whichever registry the user actually
28209+
// targets.
28210+
async function getMaxPublishedVersion(name, cwd, registries) {
28211+
let max = null;
28212+
for (const registry of registries) {
28213+
const v = await getPublishedVersion(name, cwd, registry);
28214+
if (v === null) continue;
28215+
if (max === null || compareSemver(v, max) > 0) max = v;
28216+
}
28217+
return max;
28218+
}
28219+
28220+
// Run `worker(item)` over `items` with at most `limit` concurrent in-flight
28221+
// calls. Preserves input order in the returned array.
28222+
//
28223+
// `cargo info` is cheap CPU-wise but spawns a process and hits the network
28224+
// per call. In a 100-crate workspace, `Promise.all(...)` would launch 100
28225+
// concurrent processes and 100 simultaneous registry requests, which can
28226+
// overwhelm a small runner and trigger rate limits. A small pool keeps
28227+
// resource use predictable while still giving real parallelism.
28228+
async function asyncPool(limit, items, worker) {
28229+
const results = new Array(items.length);
28230+
let cursor = 0;
28231+
const runner = async () => {
28232+
while (true) {
28233+
const i = cursor++;
28234+
if (i >= items.length) return;
28235+
results[i] = await worker(items[i]);
28236+
}
28237+
};
28238+
const workerCount = Math.max(1, Math.min(limit, items.length));
28239+
await Promise.all(Array.from({ length: workerCount }, runner));
28240+
return results;
28241+
}
28242+
2808928243
function parseMetadata(metadata) {
2809028244
const packages = [];
28091-
const publish = [];
28245+
const publishCandidates = [];
2809228246
const matrix = [];
2809328247
let rustVersion = null;
2809428248
let edition = null;
2809528249
for (const pkg of metadata.packages ?? []) {
2809628250
packages.push(pkg.name);
2809728251
if (isPublishable(pkg)) {
28098-
publish.push(pkg.name);
28252+
// `publish = ["a", "b", ...]` declares every registry the crate may
28253+
// be published to. We need to query *all* of them and compare against
28254+
// the highest version found, since the user picks the target with
28255+
// `cargo publish --registry <X>` at publish time. `publish = null`
28256+
// means unrestricted → just the default (`crates-io`).
28257+
const registries =
28258+
Array.isArray(pkg.publish) && pkg.publish.length > 0
28259+
? [...pkg.publish]
28260+
: [null];
28261+
publishCandidates.push({
28262+
name: pkg.name,
28263+
version: pkg.version,
28264+
registries,
28265+
});
2809928266
}
2810028267
// Sort so matrix output is stable regardless of cargo's feature
2810128268
// emission order (currently a BTreeMap, but not contractually so).
@@ -28127,16 +28294,53 @@ function parseMetadata(metadata) {
2812728294
}
2812828295
return {
2812928296
packages,
28130-
publish,
28297+
publishCandidates,
2813128298
matrix,
2813228299
rustVersion: rustVersion ?? "",
2813328300
edition: edition ?? "",
2813428301
};
2813528302
}
2813628303

28137-
function writeOutputs(metadata) {
28138-
const { packages, publish, matrix, rustVersion, edition } =
28304+
// Cap on concurrent `cargo info` invocations. Empirically, ~4 saturates a
28305+
// GitHub-hosted runner without triggering registry rate limits.
28306+
const PUBLISH_CHECK_CONCURRENCY = 4;
28307+
28308+
// Filter publish candidates down to those whose local version is strictly
28309+
// newer than the registry version. Packages that aren't on any of their
28310+
// declared registries are kept (first publish). Network/parse failures are
28311+
// surfaced as warnings and the candidate is skipped, so a transient outage
28312+
// can't accidentally republish an already-published crate.
28313+
async function filterPublishable(candidates, cwd) {
28314+
const resolved = await asyncPool(
28315+
PUBLISH_CHECK_CONCURRENCY,
28316+
candidates,
28317+
async ({ name, version, registries }) => {
28318+
let published;
28319+
try {
28320+
published = await getMaxPublishedVersion(name, cwd, registries);
28321+
} catch (err) {
28322+
(0,_actions_core__WEBPACK_IMPORTED_MODULE_0__/* .warning */ .$e)(`${name}: cargo info failed (${err.message}); skipping`);
28323+
return null;
28324+
}
28325+
if (published === null) {
28326+
(0,_actions_core__WEBPACK_IMPORTED_MODULE_0__/* .info */ .pq)(`${name}: not on registry — including for first publish`);
28327+
return name;
28328+
}
28329+
if (compareSemver(version, published) > 0) {
28330+
(0,_actions_core__WEBPACK_IMPORTED_MODULE_0__/* .info */ .pq)(`${name}: ${published} → ${version} (publishable)`);
28331+
return name;
28332+
}
28333+
(0,_actions_core__WEBPACK_IMPORTED_MODULE_0__/* .info */ .pq)(`${name}: ${version} <= published ${published} (skipping)`);
28334+
return null;
28335+
},
28336+
);
28337+
return resolved.filter((name) => name !== null);
28338+
}
28339+
28340+
async function writeOutputs(metadata, cwd) {
28341+
const { packages, publishCandidates, matrix, rustVersion, edition } =
2813928342
parseMetadata(metadata);
28343+
const publish = await filterPublishable(publishCandidates, cwd);
2814028344
(0,_actions_core__WEBPACK_IMPORTED_MODULE_0__/* .setOutput */ .uH)("metadata", JSON.stringify(metadata));
2814128345
(0,_actions_core__WEBPACK_IMPORTED_MODULE_0__/* .setOutput */ .uH)("packages", JSON.stringify(packages));
2814228346
(0,_actions_core__WEBPACK_IMPORTED_MODULE_0__/* .setOutput */ .uH)("publish", JSON.stringify(publish));
@@ -28149,7 +28353,8 @@ async function run() {
2814928353
const manifestPath = (0,_actions_core__WEBPACK_IMPORTED_MODULE_0__/* .getInput */ .V4)("manifest-path");
2815028354
ensureToolchain(manifestPath);
2815128355
const metadata = await runCargoMetadata(manifestPath);
28152-
writeOutputs(metadata);
28356+
const cwd = (0,path__WEBPACK_IMPORTED_MODULE_2__.dirname)((0,path__WEBPACK_IMPORTED_MODULE_2__.resolve)(manifestPath));
28357+
await writeOutputs(metadata, cwd);
2815328358
}
2815428359

2815528360

@@ -28164,10 +28369,11 @@ __nccwpck_require__.d(__webpack_exports__, {
2816428369
V4: () => (/* binding */ getInput),
2816528370
pq: () => (/* binding */ info),
2816628371
C1: () => (/* binding */ setFailed),
28167-
uH: () => (/* binding */ setOutput)
28372+
uH: () => (/* binding */ setOutput),
28373+
$e: () => (/* binding */ warning)
2816828374
});
2816928375

28170-
// UNUSED EXPORTS: ExitCode, addPath, debug, endGroup, error, exportVariable, getBooleanInput, getIDToken, getMultilineInput, getState, group, isDebug, markdownSummary, notice, platform, saveState, setCommandEcho, setSecret, startGroup, summary, toPlatformPath, toPosixPath, toWin32Path, warning
28376+
// UNUSED EXPORTS: ExitCode, addPath, debug, endGroup, error, exportVariable, getBooleanInput, getIDToken, getMultilineInput, getState, group, isDebug, markdownSummary, notice, platform, saveState, setCommandEcho, setSecret, startGroup, summary, toPlatformPath, toPosixPath, toWin32Path
2817128377

2817228378
;// CONCATENATED MODULE: external "os"
2817328379
const external_os_namespaceObject = __WEBPACK_EXTERNAL_createRequire(import.meta.url)("os");
@@ -31012,7 +31218,7 @@ function error(message, properties = {}) {
3101231218
* @param properties optional properties to add to the annotation.
3101331219
*/
3101431220
function warning(message, properties = {}) {
31015-
issueCommand('warning', toCommandProperties(properties), message instanceof Error ? message.toString() : message);
31221+
command_issueCommand('warning', utils_toCommandProperties(properties), message instanceof Error ? message.toString() : message);
3101631222
}
3101731223
/**
3101831224
* Adds a notice issue

0 commit comments

Comments
 (0)