Skip to content

Commit b71a65a

Browse files
Fix gpg hanging waiting for input when more than one key is present in keychain (#5471)
Mill's artifact signing for publishing needs `gpg` to import the signing keys. However, the current implementation naively assumes that the key it imports is the only key. If you have other keys in your keychain, a couple of things could happen: - `gpg` by default signs using the first key in your keychain, which isn't necessarily the key you just imported, so your artifact can be signed using the wrong key. - If the key `gpg` picks has a passphrase, `gpg` will hang indefinitely waiting for you to enter the passphrase to stdin, which will never happen, as it's mill handling the stdin. This PR ensures that when the key is imported into GPG, its ID is extracted and then used in the signing commands to specify which key to use. Tested manually on Linux and Mac. --------- Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
1 parent 90f0ecb commit b71a65a

File tree

4 files changed

+145
-36
lines changed

4 files changed

+145
-36
lines changed

contrib/sonatypecentral/readme.adoc

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ $ mill -i \
3030
mill.contrib.sonatypecentral/publishAll \
3131
--username myusername \
3232
--password mypassword \
33-
--gpgArgs --passphrase=$MILL_PGP_PASSPHRASE,--no-tty,--pinentry-mode,loopback,--batch,--yes,-a,-b \
33+
--gpgArgs --passphrase=$MILL_PGP_PASSPHRASE,--no-tty,--pinentry-mode,loopback,--batch,--yes,--armor,--detach-sign \
3434
--publishArtifacts __.publishArtifacts \
3535
--readTimeout 36000 \
3636
--awaitTimeout 36000 \
@@ -69,7 +69,7 @@ The `mill.contrib.sonatypecentral/publishAll` method takes the following argumen
6969

7070
`password`: The password for calling the Sonatype Central publishing api. Defaults to the `SONATYPE_PASSWORD` environment variable if unset. If neither the parameter nor the environment variable are set, an error will be thrown. +
7171

72-
`gpgArgs`: Arguments to pass to the gpg package for signing artifacts. Uses the `MILL_PGP_PASSPHRASE` environment variable if set. _Default: `[--passphrase=$MILL_PGP_PASSPHRASE], --no-tty, --pinentry-mode, loopback, --batch, --yes, -a, -b`._ +
72+
`gpgArgs`: Arguments to pass to the gpg package for signing artifacts. Uses the `MILL_PGP_PASSPHRASE` environment variable if set. _Default: `[--passphrase=$MILL_PGP_PASSPHRASE], --no-tty, --pinentry-mode, loopback, --batch, --yes, --armor, --detach-sign`._ +
7373

7474
`publishArtifacts`: The command for generating all publishable artifacts (ex. `__.publishArtifacts`). Required. +
7575

libs/javalib/src/mill/javalib/PublishModule.scala

Lines changed: 123 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -510,14 +510,13 @@ trait PublishModule extends JavaModule { outer =>
510510
stagingRelease: Boolean = true
511511
): Task.Command[Unit] = Task.Command {
512512
val PublishModule.PublishData(artifactInfo, artifacts) = publishArtifacts()
513-
PublishModule.pgpImportSecretIfProvided(Task.env)
513+
val gpgArgs0 = PublishModule.pgpImportSecretIfProvidedAndMakeGpgArgs(Task.env, gpgArgs)
514514
new SonatypePublisher(
515515
sonatypeUri,
516516
sonatypeSnapshotUri,
517517
checkSonatypeCreds(sonatypeCreds)(),
518518
signed,
519-
if (gpgArgs.isEmpty) PublishModule.defaultGpgArgsForPassphrase(Task.env.get("PGP_PASSPHRASE"))
520-
else gpgArgs,
519+
gpgArgs0,
521520
readTimeout,
522521
connectTimeout,
523522
Task.log,
@@ -545,26 +544,128 @@ trait PublishModule extends JavaModule { outer =>
545544
object PublishModule extends ExternalModule with TaskModule {
546545
def defaultTask(): String = "publishAll"
547546
val defaultGpgArgs: Seq[String] = defaultGpgArgsForPassphrase(None)
548-
def pgpImportSecretIfProvided(env: Map[String, String]): Unit = {
549-
for (secret <- env.get("MILL_PGP_SECRET_BASE64")) {
550-
os.call(
551-
("gpg", "--import", "--no-tty", "--batch", "--yes"),
552-
stdin = java.util.Base64.getDecoder.decode(secret)
553-
)
547+
548+
/**
549+
* Imports a Base64 encoded GPG secret, if one is provided in the environment.
550+
*
551+
* @return Some(Right(the key ID of the imported secret)), Some(Left(error message)) if the import failed, None if
552+
* the environment variable is not set.
553+
*/
554+
def pgpImportSecretIfProvided(env: Map[String, String]): Option[Either[String, String]] = {
555+
for (secret <- env.get(EnvVarPgpSecretBase64)) yield {
556+
pgpImportSecret(secret).left.map { errorLines =>
557+
s"""Could not import PGP secret from environment variable '$EnvVarPgpSecretBase64'. gpg output:
558+
|
559+
|${errorLines.mkString("\n")}""".stripMargin
560+
}
554561
}
555562
}
556563

557-
def defaultGpgArgsForPassphrase(passphrase: Option[String]): Seq[String] = {
558-
passphrase.map("--passphrase=" + _).toSeq ++
559-
Seq(
560-
"--no-tty",
561-
"--pinentry-mode",
562-
"loopback",
563-
"--batch",
564-
"--yes",
565-
"-a",
566-
"-b"
564+
/** Imports a Base64 encoded GPG secret, if one is provided in the environment. Throws if the import fails. */
565+
def pgpImportSecretIfProvidedOrThrow(env: Map[String, String]): Option[String] =
566+
pgpImportSecretIfProvided(env).map(_.fold(
567+
err => throw new IllegalArgumentException(err),
568+
identity
569+
))
570+
571+
/**
572+
* Imports a Base64 encoded GPG secret.
573+
*
574+
* @return Right(the key ID of the imported secret), or Left(gnupg output) if the import failed.
575+
*/
576+
def pgpImportSecret(secretBase64: String): Either[Vector[String], String] = {
577+
val cmd = Seq(
578+
"gpg",
579+
"--import",
580+
"--no-tty",
581+
"--batch",
582+
"--yes",
583+
// Use the machine parseable output format and send it to stdout.
584+
"--with-colons",
585+
"--status-fd",
586+
"1"
587+
)
588+
val res = os.call(cmd, stdin = java.util.Base64.getDecoder.decode(secretBase64))
589+
val outLines = res.out.lines()
590+
val importRegex = """^\[GNUPG:\] IMPORT_OK \d+ (\w+)""".r
591+
outLines.collectFirst { case importRegex(key) => key }.toRight(outLines)
592+
}
593+
594+
def defaultGpgArgsForPassphrase(passphrase: Option[GpgKey]): Seq[String] = {
595+
passphrase.iterator.flatMap(_.gpgArgs).toSeq ++ Seq(
596+
"--no-tty",
597+
"--pinentry-mode",
598+
"loopback",
599+
"--batch",
600+
"--yes",
601+
"--armor",
602+
"--detach-sign"
603+
)
604+
}
605+
606+
def pgpImportSecretIfProvidedAndMakeGpgArgs(
607+
env: Map[String, String],
608+
providedGpgArgs: Seq[String]
609+
): Seq[String] = {
610+
val maybeKeyId = pgpImportSecretIfProvidedOrThrow(env)
611+
makeGpgArgs(env, maybeKeyId, providedGpgArgs)
612+
}
613+
614+
def makeGpgArgs(
615+
env: Map[String, String],
616+
maybeKeyId: Option[String],
617+
providedGpgArgs: Seq[String]
618+
): Seq[String] = {
619+
if (providedGpgArgs.nonEmpty) providedGpgArgs
620+
else {
621+
val maybePassphrase = GpgKey.createFromEnvVarsOrThrow(
622+
maybeKeyId = maybeKeyId,
623+
maybePassphrase = env.get(EnvVarPgpPassphrase)
567624
)
625+
defaultGpgArgsForPassphrase(maybePassphrase)
626+
}
627+
}
628+
629+
val EnvVarPgpPassphrase = "MILL_PGP_PASSPHRASE"
630+
val EnvVarPgpSecretBase64 = "MILL_PGP_SECRET_BASE64"
631+
632+
case class GpgKey private (keyId: String, passphrase: Option[String]) {
633+
def gpgArgs: Seq[String] =
634+
Seq("--local-user", keyId) ++ passphrase.iterator.flatMap(p => Seq("--passphrase", p))
635+
}
636+
637+
object GpgKey {
638+
639+
/** Creates an instance if the passphrase is not empty. */
640+
def apply(keyId: String, passphrase: Option[String]): GpgKey =
641+
new GpgKey(keyId = keyId, passphrase = passphrase.filter(_.nonEmpty))
642+
643+
/** Creates an instance if the passphrase is not empty. */
644+
def apply(keyId: String, passphrase: String): GpgKey =
645+
new GpgKey(keyId = keyId, passphrase = if (passphrase.isEmpty) None else Some(passphrase))
646+
647+
/**
648+
* @param maybeKeyId will be [[None]] if the PGP key was not provided in the environment.
649+
* @param maybePassphrase will be [[None]] if the PGP passphrase was not provided in the environment.
650+
*/
651+
def createFromEnvVars(
652+
maybeKeyId: Option[String],
653+
maybePassphrase: Option[String]
654+
): Option[Either[String, GpgKey]] =
655+
(maybeKeyId, maybePassphrase) match {
656+
case (None, None) => None
657+
case (Some(keyId), maybePassphrase) => Some(Right(apply(keyId = keyId, maybePassphrase)))
658+
// If passphrase is provided, key is required.
659+
case (None, Some(_)) =>
660+
Some(Left("A passphrase was provided, but key was not successfully imported."))
661+
}
662+
663+
def createFromEnvVarsOrThrow(
664+
maybeKeyId: Option[String],
665+
maybePassphrase: Option[String]
666+
): Option[GpgKey] =
667+
createFromEnvVars(maybeKeyId, maybePassphrase)
668+
.map(_.fold(err => throw new IllegalArgumentException(err), identity))
568669
}
569670

570671
case class PublishData(meta: Artifact, payload: Seq[(PathRef, String)]) {
@@ -592,7 +693,7 @@ object PublishModule extends ExternalModule with TaskModule {
592693
* <i>Note: consider using environment variables over this argument due
593694
* to security reasons.</i>
594695
* @param signed
595-
* @param gpgArgs GPG arguments. Defaults to `--passphrase=$MILL_PGP_PASSPHRASE,--no-tty,--pienty-mode,loopback,--batch,--yes,-a,-b`.
696+
* @param gpgArgs GPG arguments. Defaults to [[defaultGpgArgsForPassphrase]].
596697
* Specifying this will override/remove the defaults.
597698
* Add the default args to your args to keep them.
598699
* @param release Whether to release the artifacts after staging them
@@ -624,15 +725,14 @@ object PublishModule extends ExternalModule with TaskModule {
624725
case PublishModule.PublishData(a, s) => (s.map { case (p, f) => (p.path, f) }, a)
625726
}
626727

627-
pgpImportSecretIfProvided(Task.env)
728+
val gpgArgs0 = pgpImportSecretIfProvidedAndMakeGpgArgs(Task.env, gpgArgs.split(','))
628729

629730
new SonatypePublisher(
630731
sonatypeUri,
631732
sonatypeSnapshotUri,
632733
checkSonatypeCreds(sonatypeCreds)(),
633734
signed,
634-
if (gpgArgs.isEmpty) defaultGpgArgsForPassphrase(Task.env.get("MILL_PGP_PASSPHRASE"))
635-
else gpgArgs.split(','),
735+
gpgArgs0,
636736
readTimeout,
637737
connectTimeout,
638738
Task.log,

libs/javalib/src/mill/javalib/SonatypeCentralPublishModule.scala

Lines changed: 18 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -23,8 +23,12 @@ import mill.scalalib.publish.SonatypeHelpers.{
2323
import mill.api.BuildCtx
2424

2525
trait SonatypeCentralPublishModule extends PublishModule {
26-
def sonatypeCentralGpgArgs: T[String] = Task {
27-
PublishModule.defaultGpgArgsForPassphrase(Task.env.get("MILL_PGP_PASSPHRASE")).mkString(",")
26+
27+
/**
28+
* @return (keyId => gpgArgs), where maybeKeyId is the PGP key that was imported and should be used for signing.
29+
*/
30+
def sonatypeCentralGpgArgs: Task[String => Seq[String]] = Task.Anon { (keyId: String) =>
31+
PublishModule.makeGpgArgs(Task.env, maybeKeyId = Some(keyId), providedGpgArgs = Seq.empty)
2832
}
2933

3034
def sonatypeCentralConnectTimeout: T[Int] = Task { defaultConnectTimeout }
@@ -42,12 +46,19 @@ trait SonatypeCentralPublishModule extends PublishModule {
4246
Task.Command {
4347
val publishData = publishArtifacts()
4448
val fileMapping = publishData.withConcretePath._1
49+
50+
val maybeKeyId = PublishModule.pgpImportSecretIfProvidedOrThrow(Task.env)
51+
val keyId = maybeKeyId.getOrElse(throw new IllegalArgumentException(
52+
s"Publishing to Sonatype Central requires a PGP key. Please set the '${PublishModule.EnvVarPgpSecretBase64}' " +
53+
s"and '${PublishModule.EnvVarPgpPassphrase}' (if needed) environment variables."
54+
))
55+
56+
val gpgArgs = sonatypeCentralGpgArgs()(keyId)
4557
val artifact = publishData.meta
4658
val finalCredentials = getSonatypeCredentials(username, password)()
47-
PublishModule.pgpImportSecretIfProvided(Task.env)
4859
val publisher = new SonatypeCentralPublisher(
4960
credentials = finalCredentials,
50-
gpgArgs = sonatypeCentralGpgArgs().split(",").toIndexedSeq,
61+
gpgArgs = gpgArgs,
5162
connectTimeout = sonatypeCentralConnectTimeout(),
5263
readTimeout = sonatypeCentralReadTimeout(),
5364
log = Task.log,
@@ -98,13 +109,11 @@ object SonatypeCentralPublishModule extends ExternalModule with TaskModule {
98109

99110
val finalBundleName = if (bundleName.isEmpty) None else Some(bundleName)
100111
val finalCredentials = getSonatypeCredentials(username, password)()
101-
PublishModule.pgpImportSecretIfProvided(Task.env)
112+
val gpgArgs0 =
113+
PublishModule.pgpImportSecretIfProvidedAndMakeGpgArgs(Task.env, gpgArgs.split(','))
102114
val publisher = new SonatypeCentralPublisher(
103115
credentials = finalCredentials,
104-
gpgArgs = gpgArgs match {
105-
case "" => PublishModule.defaultGpgArgsForPassphrase(Task.env.get("MILL_PGP_PASSPHRASE"))
106-
case gpgArgs => gpgArgs.split(",").toIndexedSeq
107-
},
116+
gpgArgs = gpgArgs0,
108117
connectTimeout = connectTimeout,
109118
readTimeout = readTimeout,
110119
log = Task.log,

website/docs/modules/ROOT/partials/Publishing_Footer.adoc

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -261,7 +261,7 @@ The `./mill mill.javalib.SonatypeCentralPublishModule/publishAll` takes the foll
261261

262262
`password`: The password for calling the Sonatype Central publishing api. Defaults to the `SONATYPE_PASSWORD` environment variable if unset. If neither the parameter nor the environment variable are set, an error will be thrown. +
263263

264-
`gpgArgs`: Arguments to pass to the gpg package for signing artifacts. Uses the `MILL_PGP_PASSPHRASE` environment variable if set. _Default: `[--passphrase=$MILL_PGP_PASSPHRASE], --no-tty, --pinentry-mode, loopback, --batch, --yes, -a, -b`._ +
264+
`gpgArgs`: Arguments to pass to the gpg package for signing artifacts. Uses the `MILL_PGP_PASSPHRASE` environment variable if set. _Default: `[--passphrase=$MILL_PGP_PASSPHRASE], --no-tty, --pinentry-mode, loopback, --batch, --yes, --armor, --detach-sign`._ +
265265

266266
`publishArtifacts`: The command for generating all publishable artifacts (ex. `__.publishArtifacts`). Required. +
267267

@@ -282,7 +282,7 @@ $ mill -i \
282282
mill.javalib.SonatypeCentralPublishModule/publishAll \
283283
--username myusername \
284284
--password mypassword \
285-
--gpgArgs --passphrase=$MILL_PGP_PASSPHRASE,--no-tty,--pinentry-mode,loopback,--batch,--yes,-a,-b \
285+
--gpgArgs --passphrase=$MILL_PGP_PASSPHRASE,--no-tty,--pinentry-mode,loopback,--batch,--yes,--armor,--detach-sign \
286286
--publishArtifacts __.publishArtifacts \
287287
--readTimeout 36000 \
288288
--awaitTimeout 36000 \

0 commit comments

Comments
 (0)