///|
let npm_registry : String = "https://registry.npmjs.org"
///|
fn transient(text : String) -> Bool {
let text = text.to_lower()
if [
"401", "403", "409", "eauth", "eperm", "einvalid", "forbidden", "permission",
"unauthorized", "duplicated", "e404",
].any(w => text.contains(w)) {
return false
}
[
"timeout", "timed out", "connection reset", "connection refused", "econnreset",
"econnrefused", "eai_again", "502", "503", "504", "429",
].any(w => text.contains(w))
}
///|
/// A retry is safe only for a proven pre-upload refusal. Other transient
/// upload errors must first be reconciled against the registry.
fn safe_upload_retry(text : String) -> Bool {
let text = text.to_lower()
text.contains("connection refused") ||
text.contains("econnrefused") ||
text.contains("eai_again")
}
///|
async fn query(
host : Host,
cmd : String,
args : Array[String],
cwd : String,
) -> (Int32, String, String) {
let mut result = host.call(cmd, args, cwd)
for attempt in 0..<3 {
if result.0 == 0 || !transient(result.1 + result.2) {
break
}
eprintln("retrying registry query \{attempt+2}/4")
@async.sleep([50, 200, 500][attempt])
result = host.call(cmd, args, cwd)
}
result
}
///|
async fn check_registry_destinations() -> Unit {
if @env.get_env_var("MOONCAKES_REGISTRY") is Some(r) &&
r.trim_end(chars="/") != "https://mooncakes.io" {
invalid("MOONCAKES_REGISTRY must select the public Mooncakes registry")
}
let home = @env.get_env_var("MOON_HOME").unwrap_or(
@env.get_env_var("HOME").unwrap_or("") + "/.moon",
)
if @fs.exists(home + "/config.json") {
let config = read_json(home + "/config.json")
let api = opt_string(
config,
"api",
opt_string(config, "registry", "https://mooncakes.io"),
)
if api.trim_end(chars="/") != "https://mooncakes.io" {
invalid("Moon config.json redirects the public registry")
}
for
(key, expected) in [
("index", "https://mooncakes.io/git/index"),
("download", "https://download.mooncakes.io"),
] {
if opt_string(config, key, expected).trim_end(chars="/") != expected {
invalid("Moon registry override: " + key)
}
}
}
}
///|
fn npm_args(args : Array[String], name : String) -> Array[String] {
if name.has_prefix("@") {
args.push(
"--" +
name.split("/").next().unwrap_or("").to_owned() +
":registry=" +
npm_registry,
)
}
args
}
///|
async fn npm_destinations(host : Host, name : String, cwd : String) -> Unit {
let keys = ["registry"]
if name.has_prefix("@") {
keys.push(name.split("/").next().unwrap_or("").to_owned() + ":registry")
}
for key in keys {
let (code, out, err) = host.call("npm", ["config", "get", key], cwd)
let value = out.trim().trim_end(chars="/").to_owned()
if code != 0 {
failed(
"REGISTRY_CONFIG",
"cannot inspect npm registry configuration: " + redact(err),
)
}
if value != "undefined" && value != "null" && value != npm_registry {
invalid(
"npm registry override for " +
key +
": only the public registry is supported",
)
}
}
}
///|
async fn moon_exists(host : Host, b : Json, cwd : String) -> Bool {
let (code, out, err) = query(
host,
"moon",
[
"view",
string_field(b, "module") + "@" + string_field(b, "version"),
"--json",
],
cwd,
)
if code == 0 {
let report = @json.parse(out)
if field(report, "status") != "success" {
failed("REGISTRY_QUERY", "Mooncakes did not return success")
}
let result = field(report, "result")
if field(result, "module") != field(b, "module") ||
field(field(result, "release"), "version") != field(b, "version") {
failed("REGISTRY_IDENTITY", "Mooncakes returned another module/version")
}
return true
}
if (out + err).to_lower().contains("404 not found") {
return false
}
failed(
"REGISTRY_QUERY",
"Mooncakes version query failed: " + redact(out + err),
)
}
///|
async fn npm_version(host : Host, b : Json, cwd : String) -> Json {
let (code, out, err) = query(
host,
"npm",
npm_args(
[
"view",
string_field(b, "npm") + "@" + string_field(b, "version"),
"--json",
"--registry=" + npm_registry,
],
string_field(b, "npm"),
),
cwd,
)
if code == 0 {
let info = @json.parse(out)
if field(info, "name") != field(b, "npm") ||
field(info, "version") != field(b, "version") {
failed("REGISTRY_IDENTITY", "npm returned another package/version")
}
return info
}
if (out + err).contains("E404") {
return Json::null()
}
failed("REGISTRY_QUERY", "npm version query failed: " + redact(out + err))
}
///|
fn npm_matches(info : Json, b : Json, tag : String) -> Bool raise {
if info == Json::null() {
return false
}
if field(field(info, "dist"), "integrity") != field(b, "integrity") {
failed("CONTENT_CONFLICT", "npm version exists with different integrity")
}
if field(field(info, "dist-tags"), tag) != field(b, "version") {
failed(
"TAG_CONFLICT", "npm artifact matches, but requested tag points elsewhere; reconcile externally",
)
}
true
}
///|
fn publish_tag(b : Json) -> String raise {
let v = string_field(b, "version")
let tag = opt_string(b, "tag", "")
if v.split("+").next().unwrap_or("").contains("-") && tag.is_empty() {
invalid("prereleases require an explicit npm tag")
}
let tag = if tag.is_empty() { "latest" } else { tag }
if !identifiers(tag, false) || tag.has_prefix("-") || semver(tag) {
invalid("invalid npm tag")
}
tag
}
///|
struct Publication {
host : Host
root : String
bundle : Json
digest : String
outcomes : Map[String, Json]
}
///|
fn Publication::report(self : Publication) -> Json {
{
"dryRun": false,
"bundle": self.root,
"registries": Json::object(self.outcomes),
"recovery": "Preserve this bundle and receipts. Resolve conflicts/uncertainty externally, then rerun pubman publish --bundle " +
self.root,
}
}
///|
async fn Publication::record(
self : Publication,
registry : String,
status : String,
message : String,
) -> Unit {
let receipt : Json = {
"schemaVersion": 1,
"bundleDigest": self.digest,
"status": status,
"artifactDigest": string_field(
field(self.bundle, "files"),
if registry == "moon" {
"source.zip"
} else {
"package.tgz"
},
),
"message": redact(message),
}
write_json(
self.host,
self.root + "/publication-" + registry + ".json",
receipt,
)
self.outcomes[registry] = receipt
}
///|
async fn Publication::confirmed(self : Publication, registry : String) -> Bool {
let path = self.root + "/publication-" + registry + ".json"
if !@fs.exists(path) {
return false
}
let receipt = read_json(path)
if field(receipt, "bundleDigest") != Json::string(self.digest) ||
field(receipt, "artifactDigest") !=
field(
field(self.bundle, "files"),
if registry == "moon" {
"source.zip"
} else {
"package.tgz"
},
) {
failed(
"STALE_PUBLICATION", "publication receipt belongs to different artifacts",
)
}
let status = string_field(receipt, "status")
status == "published" || status == "already-published"
}
///|
async fn publish_state(p : Publication, host : Host) -> Unit {
let b = p.bundle
let tag = publish_tag(b)
// All preflight, including both credentials, completes before either write.
npm_destinations(host, string_field(b, "npm"), p.root)
let npm_checked = npm_auth(p, host, oidc_available(@env.get_env_vars()))
let (mc, mo, _) = query(host, "moon", ["whoami"], p.root)
let owner = string_field(b, "module")
.split("/")
.next()
.unwrap_or("")
.to_owned()
if mc != 0 || mo.trim() != "Logged in as " + owner {
failed(
"AUTH_REQUIRED", "moon login must provide the module owner's identity",
)
}
let exists = moon_exists(host, b, p.root)
let moon_confirmed = p.confirmed("moon")
if exists && !moon_confirmed {
failed(
"MOONCAKES_UNCERTAIN", "Mooncakes version exists without matching successful receipt; reconcile externally",
)
}
if moon_confirmed && !exists {
failed(
"REGISTRY_CONFLICT", "Mooncakes receipt exists but registry version is absent",
)
}
let npm_info = npm_version(host, b, p.root)
let npm_done = npm_matches(npm_info, b, tag)
if !npm_done && !npm_checked {
let (code, out, err) = npm_probe(p, host)
if code != 0 {
failed(
"NPM_PREFLIGHT",
"npm rejected the captured candidate before registry writes: " +
redact(out + err),
)
}
}
if exists {
p.record(
"moon", "already-published", "confirmed by receipt and registry existence",
)
}
if npm_done {
p.record(
"npm", "already-published", "confirmed exact integrity and requested tag",
)
}
if !exists {
let work = host.tmpdir(prefix="pubman-publish-")
defer host.rmdir(work, recursive=true)
let source = work + "/source"
extract_source(host, p.root + "/payload/source.zip", source)
if inventory(source) != read_json(p.root + "/payload/source-inventory.json") {
failed("SOURCE_INVENTORY", "Mooncakes staging inventory differs")
}
// Inventory round-trip without a compiler or dependency resolver.
ignore(
host.checked("zip", ["-q", "-r", work + "/repacked.zip", "."], source),
)
extract_source(host, work + "/repacked.zip", work + "/repacked")
if inventory(work + "/repacked") !=
read_json(p.root + "/payload/source-inventory.json") {
failed("SOURCE_INVENTORY", "Mooncakes repack differs")
}
// Record intent before upload so a crash cannot look like a safe first attempt.
p.record(
"moon", "uncertain", "upload starting; a missing success response requires external reconciliation",
)
for attempt in 0..<4 {
let (code, out, err) = host.call(
"moon",
["publish", "--frozen", "--target-dir", work + "/publish-output"],
source,
)
let message = redact(out + err)
eprintln(message.trim_end())
if code == 0 {
p.record("moon", "published", "Mooncakes accepted captured source")
break
}
let after = moon_exists(host, b, p.root) catch { _ => true }
if after || (transient(message) && !safe_upload_retry(message)) {
p.record("moon", "uncertain", message)
failed(
"PUBLICATION_UNCERTAIN", "Mooncakes upload may have succeeded; preserve receipts and reconcile externally",
)
}
if !transient(message) || attempt == 3 {
p.record("moon", "failed", message)
failed(
"PUBLICATION_FAILED", "Mooncakes refused publication; correct authentication/content and rerun the same bundle",
)
}
@async.sleep([50, 200, 500][attempt])
}
}
if !npm_done {
p.record(
"npm", "uncertain", "upload starting; reconcile integrity and tag before retry",
)
for attempt in 0..<4 {
let (code, out, err) = host.call(
"npm",
npm_args(
[
"publish",
p.root + "/payload/package.tgz",
"--ignore-scripts",
"--access=public",
"--registry=" + npm_registry,
"--tag",
tag,
],
string_field(b, "npm"),
),
p.root,
)
let message = redact(out + err)
eprintln(message.trim_end())
// Even success must confirm the registry artifact and tag.
let after = npm_version(host, b, p.root) catch {
_ => {
p.record("npm", "uncertain", "cannot reconcile upload response")
failed("PUBLICATION_UNCERTAIN", "npm response cannot be reconciled")
}
}
if npm_matches(after, b, tag) {
p.record(
"npm",
if code == 0 {
"published"
} else {
"already-published"
},
"exact integrity and tag confirmed",
)
break
}
if !transient(message) || attempt == 3 {
p.record("npm", if code == 0 { "uncertain" } else { "failed" }, message)
failed(
"PUBLICATION_FAILED", "npm not confirmed; preserve Mooncakes receipt and rerun the same bundle",
)
}
@async.sleep([50, 200, 500][attempt])
}
}
}
///|
async fn publish_bundle(root : String, b : Json, host : Host) -> Json {
let lock = host.create(root + "/.operation-lock", allow_existing=false)
defer lock.close()
defer host.remove(root + "/.operation-lock")
let (_, fresh) = bundle_read(root)
if fresh != b || !verification_valid(root, b, host) {
failed(
"VERIFICATION_REQUIRED", "pack and verify the exact bundle with current tools before publication",
)
}
check_registry_destinations()
let p : Publication = {
host,
root,
bundle: b,
digest: file_digest(root + "/bundle.json"),
outcomes: {
"moon": { "status": "pending" },
"npm": { "status": "pending" },
},
}
publish_state(p, host) catch {
Failure(code, message, status) =>
raise Partial(code, message, status, p.report())
err =>
raise Partial(
"PUBLICATION_UNCERTAIN",
redact(err.to_string()),
1,
p.report(),
)
}
p.report()
}
///|
fn oidc_available(env : Map[String, String]) -> Bool {
let token = env.get("NPM_ID_TOKEN").unwrap_or("") != ""
(
env.get("GITHUB_ACTIONS") == Some("true") &&
(
token ||
(
env.contains("ACTIONS_ID_TOKEN_REQUEST_URL") &&
env.contains("ACTIONS_ID_TOKEN_REQUEST_TOKEN")
)
)
) ||
(
(
env.get("GITLAB_CI") == Some("true") ||
env.get("CIRCLECI") == Some("true")
) &&
token
)
}
///|
/// This is actual-publication preflight, never pubman's read-only preview.
/// npm 11.11 exchanges OIDC in its publish implementation, including when
/// upload is disabled. Delegate that exchange without persisting credentials.
async fn npm_probe(p : Publication, host : Host) -> (Int32, String, String) {
let result = query(
host,
"npm",
npm_args(
[
"publish",
p.root + "/payload/package.tgz",
"--dry-run",
"--ignore-scripts",
"--access=public",
"--json",
"--registry=" + npm_registry,
"--tag",
publish_tag(p.bundle),
"--loglevel=verbose",
],
string_field(p.bundle, "npm"),
),
p.root,
)
if !result.1.is_empty() {
eprintln(redact(result.1).trim_end())
}
if !result.2.is_empty() {
eprintln(redact(result.2).trim_end())
}
result
}
///|
async fn npm_auth(p : Publication, host : Host, oidc : Bool) -> Bool {
let (code, out, err) = query(
host,
"npm",
["whoami", "--registry=" + npm_registry],
p.root,
)
if code == 0 && !out.trim().is_empty() {
return false
}
if oidc {
let (status, output, error) = npm_probe(p, host)
if (output + error).contains("oidc Successfully retrieved and set token") {
return status == 0
}
}
failed(
"AUTH_REQUIRED",
"npm login/CI authentication is required: " + redact(err),
)
}