2023-11-23 18:38:07 -05:00
// Copyright 2018-2023 the Deno authors. All rights reserved. MIT license.
use std ::fmt ::Write ;
use std ::io ::IsTerminal ;
use std ::path ::Path ;
use std ::path ::PathBuf ;
2023-11-30 13:54:54 -05:00
use std ::rc ::Rc ;
2023-11-23 18:38:07 -05:00
use std ::sync ::Arc ;
use base64 ::prelude ::BASE64_STANDARD ;
use base64 ::Engine ;
use deno_config ::ConfigFile ;
use deno_core ::anyhow ;
use deno_core ::anyhow ::bail ;
use deno_core ::anyhow ::Context ;
use deno_core ::error ::AnyError ;
use deno_core ::serde_json ;
use deno_core ::serde_json ::json ;
use deno_runtime ::colors ;
use deno_runtime ::deno_fetch ::reqwest ;
use http ::header ::AUTHORIZATION ;
use http ::header ::CONTENT_ENCODING ;
use hyper ::body ::Bytes ;
2023-11-29 08:59:30 -05:00
use import_map ::ImportMap ;
use lsp_types ::Url ;
2023-11-23 18:38:07 -05:00
use serde ::de ::DeserializeOwned ;
use serde ::Serialize ;
use sha2 ::Digest ;
use crate ::args ::Flags ;
use crate ::args ::PublishFlags ;
use crate ::factory ::CliFactory ;
use crate ::http_util ::HttpClient ;
use crate ::util ::import_map ::ImportMapUnfurler ;
mod tar ;
enum AuthMethod {
Interactive ,
Token ( String ) ,
Oidc ( OidcConfig ) ,
}
struct OidcConfig {
url : String ,
token : String ,
}
struct PreparedPublishPackage {
scope : String ,
package : String ,
version : String ,
tarball_hash : String ,
tarball : Bytes ,
}
#[ derive(serde::Deserialize) ]
#[ serde(rename_all = " camelCase " ) ]
pub struct PublishingTaskError {
pub code : String ,
pub message : String ,
}
#[ derive(serde::Deserialize) ]
#[ serde(rename_all = " camelCase " ) ]
pub struct PublishingTask {
pub id : String ,
pub status : String ,
pub error : Option < PublishingTaskError > ,
}
async fn prepare_publish (
initial_cwd : & Path ,
directory : PathBuf ,
2023-11-29 08:59:30 -05:00
import_map : & ImportMap ,
2023-11-23 18:38:07 -05:00
) -> Result < PreparedPublishPackage , AnyError > {
let directory_path = initial_cwd . join ( directory ) ;
// TODO: doesn't handle jsonc
let deno_json_path = directory_path . join ( " deno.json " ) ;
let deno_json = ConfigFile ::read ( & deno_json_path ) . with_context ( | | {
format! (
" Failed to read deno configuration file at {} " ,
deno_json_path . display ( )
)
} ) ? ;
let Some ( version ) = deno_json . json . version . clone ( ) else {
bail! ( " {} is missing 'version' field " , deno_json_path . display ( ) ) ;
} ;
let Some ( name ) = deno_json . json . name . clone ( ) else {
bail! ( " {} is missing 'name' field " , deno_json_path . display ( ) ) ;
} ;
let Some ( name ) = name . strip_prefix ( '@' ) else {
bail! ( " Invalid package name, use '@<scope_name>/<package_name> format " ) ;
} ;
let Some ( ( scope , package_name ) ) = name . split_once ( '/' ) else {
bail! ( " Invalid package name, use '@<scope_name>/<package_name> format " ) ;
} ;
let unfurler = ImportMapUnfurler ::new ( import_map ) ;
let tarball = tar ::create_gzipped_tarball ( directory_path , unfurler )
. context ( " Failed to create a tarball " ) ? ;
let tarball_hash_bytes : Vec < u8 > =
sha2 ::Sha256 ::digest ( & tarball ) . iter ( ) . cloned ( ) . collect ( ) ;
let mut tarball_hash = " sha256- " . to_string ( ) ;
for byte in tarball_hash_bytes {
write! ( & mut tarball_hash , " {:02x} " , byte ) . unwrap ( ) ;
}
Ok ( PreparedPublishPackage {
scope : scope . to_string ( ) ,
package : package_name . to_string ( ) ,
version : version . to_string ( ) ,
tarball_hash ,
tarball ,
} )
}
#[ derive(Serialize) ]
#[ serde(tag = " permission " ) ]
pub enum Permission < ' s > {
#[ serde(rename = " package/publish " , rename_all = " camelCase " ) ]
VersionPublish {
scope : & ' s str ,
package : & ' s str ,
version : & ' s str ,
tarball_hash : & ' s str ,
} ,
}
#[ derive(serde::Deserialize) ]
#[ serde(rename_all = " camelCase " ) ]
struct CreateAuthorizationResponse {
verification_url : String ,
code : String ,
exchange_token : String ,
poll_interval : u64 ,
}
#[ derive(serde::Deserialize) ]
#[ serde(rename_all = " camelCase " ) ]
struct ExchangeAuthorizationResponse {
token : String ,
user : User ,
}
#[ derive(serde::Deserialize) ]
#[ serde(rename_all = " camelCase " ) ]
struct User {
name : String ,
}
#[ derive(serde::Deserialize) ]
#[ serde(rename_all = " camelCase " ) ]
struct ApiError {
pub code : String ,
pub message : String ,
#[ serde(skip) ]
pub x_deno_ray : Option < String > ,
}
impl std ::fmt ::Display for ApiError {
fn fmt ( & self , f : & mut std ::fmt ::Formatter < '_ > ) -> std ::fmt ::Result {
write! ( f , " {} ({}) " , self . message , self . code ) ? ;
if let Some ( x_deno_ray ) = & self . x_deno_ray {
write! ( f , " [x-deno-ray: {}] " , x_deno_ray ) ? ;
}
Ok ( ( ) )
}
}
impl std ::fmt ::Debug for ApiError {
fn fmt ( & self , f : & mut std ::fmt ::Formatter < '_ > ) -> std ::fmt ::Result {
std ::fmt ::Display ::fmt ( self , f )
}
}
impl std ::error ::Error for ApiError { }
async fn parse_response < T : DeserializeOwned > (
response : reqwest ::Response ,
) -> Result < T , ApiError > {
let status = response . status ( ) ;
let x_deno_ray = response
. headers ( )
. get ( " x-deno-ray " )
. and_then ( | value | value . to_str ( ) . ok ( ) )
. map ( | s | s . to_string ( ) ) ;
let text = response . text ( ) . await . unwrap ( ) ;
if ! status . is_success ( ) {
match serde_json ::from_str ::< ApiError > ( & text ) {
Ok ( mut err ) = > {
err . x_deno_ray = x_deno_ray ;
return Err ( err ) ;
}
Err ( _ ) = > {
let err = ApiError {
code : " unknown " . to_string ( ) ,
message : format ! ( " {}: {} " , status , text ) ,
x_deno_ray ,
} ;
return Err ( err ) ;
}
}
}
serde_json ::from_str ( & text ) . map_err ( | err | ApiError {
code : " unknown " . to_string ( ) ,
message : format ! ( " Failed to parse response: {}, response: '{}' " , err , text ) ,
x_deno_ray ,
} )
}
#[ derive(serde::Deserialize) ]
#[ serde(rename_all = " camelCase " ) ]
struct OidcTokenResponse {
value : String ,
}
async fn perform_publish (
http_client : & Arc < HttpClient > ,
packages : Vec < PreparedPublishPackage > ,
auth_method : AuthMethod ,
) -> Result < ( ) , AnyError > {
let client = http_client . client ( ) ? ;
let registry_url = crate ::cache ::DENO_REGISTRY_URL . to_string ( ) ;
2023-11-30 13:54:54 -05:00
let permissions = packages
. iter ( )
. map ( | package | Permission ::VersionPublish {
scope : & package . scope ,
package : & package . package ,
version : & package . version ,
tarball_hash : & package . tarball_hash ,
} )
. collect ::< Vec < _ > > ( ) ;
let authorizations = match auth_method {
2023-11-23 18:38:07 -05:00
AuthMethod ::Interactive = > {
let verifier = uuid ::Uuid ::new_v4 ( ) . to_string ( ) ;
let challenge = BASE64_STANDARD . encode ( sha2 ::Sha256 ::digest ( & verifier ) ) ;
let response = client
2023-11-27 12:09:33 -05:00
. post ( format! ( " {} authorizations " , registry_url ) )
2023-11-23 18:38:07 -05:00
. json ( & serde_json ::json! ( {
" challenge " : challenge ,
" permissions " : permissions ,
} ) )
. send ( )
. await
. context ( " Failed to create interactive authorization " ) ? ;
let auth = parse_response ::< CreateAuthorizationResponse > ( response )
. await
. context ( " Failed to create interactive authorization " ) ? ;
print! (
" Visit {} to authorize publishing of " ,
colors ::cyan ( format! ( " {} ?code= {} " , auth . verification_url , auth . code ) )
) ;
if packages . len ( ) > 1 {
println! ( " {} packages " , packages . len ( ) ) ;
} else {
println! ( " @ {} / {} " , packages [ 0 ] . scope , packages [ 0 ] . package ) ;
}
println! ( " {} " , colors ::gray ( " Waiting... " ) ) ;
let interval = std ::time ::Duration ::from_secs ( auth . poll_interval ) ;
loop {
tokio ::time ::sleep ( interval ) . await ;
let response = client
2023-11-27 12:09:33 -05:00
. post ( format! ( " {} authorizations/exchange " , registry_url ) )
2023-11-23 18:38:07 -05:00
. json ( & serde_json ::json! ( {
" exchangeToken " : auth . exchange_token ,
" verifier " : verifier ,
} ) )
. send ( )
. await
. context ( " Failed to exchange authorization " ) ? ;
let res =
parse_response ::< ExchangeAuthorizationResponse > ( response ) . await ;
match res {
Ok ( res ) = > {
println! (
" {} {} {} " ,
colors ::green ( " Authorization successful. " ) ,
colors ::gray ( " Authenticated as " ) ,
colors ::cyan ( res . user . name )
) ;
2023-11-30 13:54:54 -05:00
let authorization : Rc < str > = format! ( " Bearer {} " , res . token ) . into ( ) ;
let mut authorizations = Vec ::new ( ) ;
for _ in & packages {
authorizations . push ( authorization . clone ( ) ) ;
}
break authorizations ;
2023-11-23 18:38:07 -05:00
}
Err ( err ) = > {
if err . code = = " authorizationPending " {
continue ;
} else {
return Err ( err ) . context ( " Failed to exchange authorization " ) ;
}
}
}
}
}
2023-11-30 13:54:54 -05:00
AuthMethod ::Token ( token ) = > {
let authorization : Rc < str > = format! ( " Bearer {} " , token ) . into ( ) ;
let mut authorizations = Vec ::new ( ) ;
for _ in & packages {
authorizations . push ( authorization . clone ( ) ) ;
}
authorizations
}
2023-11-23 18:38:07 -05:00
AuthMethod ::Oidc ( oidc_config ) = > {
2023-11-30 13:54:54 -05:00
let mut authorizations = Vec ::new ( ) ;
for permissions in permissions . chunks ( 16 ) {
let audience = json! ( { " permissions " : permissions } ) . to_string ( ) ;
let url = format! (
" {}&audience={} " ,
oidc_config . url ,
percent_encoding ::percent_encode (
audience . as_bytes ( ) ,
percent_encoding ::NON_ALPHANUMERIC
)
2023-11-23 18:38:07 -05:00
) ;
2023-11-30 13:54:54 -05:00
let response = client
. get ( url )
. bearer_auth ( & oidc_config . token )
. send ( )
. await
. context ( " Failed to get OIDC token " ) ? ;
let status = response . status ( ) ;
let text = response . text ( ) . await . with_context ( | | {
format! ( " Failed to get OIDC token: status {} " , status )
2023-11-23 18:38:07 -05:00
} ) ? ;
2023-11-30 13:54:54 -05:00
if ! status . is_success ( ) {
bail! (
" Failed to get OIDC token: status {}, response: '{}' " ,
status ,
text
) ;
}
let OidcTokenResponse { value } = serde_json ::from_str ( & text )
. with_context ( | | {
format! (
" Failed to parse OIDC token: '{}' (status {}) " ,
text , status
)
} ) ? ;
let authorization : Rc < str > = format! ( " githuboidc {} " , value ) . into ( ) ;
2023-11-30 17:07:26 -05:00
for _ in permissions {
2023-11-30 13:54:54 -05:00
authorizations . push ( authorization . clone ( ) ) ;
}
}
authorizations
2023-11-23 18:38:07 -05:00
}
} ;
2023-11-30 13:54:54 -05:00
assert_eq! ( packages . len ( ) , authorizations . len ( ) ) ;
for ( package , authorization ) in
packages . into_iter ( ) . zip ( authorizations . into_iter ( ) )
{
2023-11-23 18:38:07 -05:00
println! (
" {} @{}/{}@{} ... " ,
colors ::intense_blue ( " Publishing " ) ,
package . scope ,
package . package ,
package . version
) ;
let url = format! (
2023-11-27 12:09:33 -05:00
" {}scopes/{}/packages/{}/versions/{} " ,
2023-11-23 18:38:07 -05:00
registry_url , package . scope , package . package , package . version
) ;
let response = client
. post ( url )
2023-11-30 13:54:54 -05:00
. header ( AUTHORIZATION , & * authorization )
2023-11-23 18:38:07 -05:00
. header ( CONTENT_ENCODING , " gzip " )
. body ( package . tarball )
. send ( )
. await ? ;
2023-12-04 06:40:58 -05:00
let res = parse_response ::< PublishingTask > ( response ) . await ;
let mut task = match res {
Ok ( task ) = > task ,
2023-12-04 09:19:01 -05:00
Err ( err ) if err . code = = " duplicateVersionPublish " = > {
2023-12-04 06:40:58 -05:00
println! (
" {} @{}/{}@{} " ,
colors ::yellow ( " Skipping, already published " ) ,
package . scope ,
package . package ,
package . version
) ;
continue ;
}
Err ( err ) = > {
return Err ( err ) . with_context ( | | {
format! (
" Failed to publish @{}/{} at {} " ,
package . scope , package . package , package . version
)
} )
}
} ;
2023-11-23 18:38:07 -05:00
let interval = std ::time ::Duration ::from_secs ( 2 ) ;
while task . status ! = " success " & & task . status ! = " failure " {
tokio ::time ::sleep ( interval ) . await ;
let resp = client
2023-11-27 12:09:33 -05:00
. get ( format! ( " {} publish_status/ {} " , registry_url , task . id ) )
2023-11-23 18:38:07 -05:00
. send ( )
. await
. with_context ( | | {
format! (
" Failed to get publishing status for @{}/{} at {} " ,
package . scope , package . package , package . version
)
} ) ? ;
task =
parse_response ::< PublishingTask > ( resp )
. await
. with_context ( | | {
format! (
" Failed to get publishing status for @{}/{} at {} " ,
package . scope , package . package , package . version
)
} ) ? ;
}
if let Some ( error ) = task . error {
bail! (
" {} @{}/{} at {}: {} " ,
colors ::red ( " Failed to publish " ) ,
package . scope ,
package . package ,
package . version ,
error . message
) ;
}
println! (
" {} @{}/{}@{} " ,
colors ::green ( " Successfully published " ) ,
package . scope ,
package . package ,
package . version
) ;
println! (
" {}/@{}/{}/{}_meta.json " ,
registry_url , package . scope , package . package , package . version
) ;
}
Ok ( ( ) )
}
fn get_gh_oidc_env_vars ( ) -> Option < Result < ( String , String ) , AnyError > > {
if std ::env ::var ( " GITHUB_ACTIONS " ) . unwrap_or_default ( ) = = " true " {
let url = std ::env ::var ( " ACTIONS_ID_TOKEN_REQUEST_URL " ) ;
let token = std ::env ::var ( " ACTIONS_ID_TOKEN_REQUEST_TOKEN " ) ;
match ( url , token ) {
( Ok ( url ) , Ok ( token ) ) = > Some ( Ok ( ( url , token ) ) ) ,
( Err ( _ ) , Err ( _ ) ) = > Some ( Err ( anyhow ::anyhow! (
" No means to authenticate. Pass a token to `--token`, or enable tokenless publishing from GitHub Actions using OIDC. Learn more at https://deno.co/ghoidc "
) ) ) ,
_ = > None ,
}
} else {
None
}
}
pub async fn publish (
flags : Flags ,
publish_flags : PublishFlags ,
) -> Result < ( ) , AnyError > {
let cli_factory = CliFactory ::from_flags ( flags ) . await ? ;
let auth_method = match publish_flags . token {
Some ( token ) = > AuthMethod ::Token ( token ) ,
None = > match get_gh_oidc_env_vars ( ) {
Some ( Ok ( ( url , token ) ) ) = > AuthMethod ::Oidc ( OidcConfig { url , token } ) ,
Some ( Err ( err ) ) = > return Err ( err ) ,
None if std ::io ::stdin ( ) . is_terminal ( ) = > AuthMethod ::Interactive ,
None = > {
bail! ( " No means to authenticate. Pass a token to `--token`. " )
}
} ,
} ;
2023-11-29 08:59:30 -05:00
let import_map = cli_factory
. maybe_import_map ( )
. await ?
. clone ( )
. unwrap_or_else ( | | {
Arc ::new ( ImportMap ::new ( Url ::parse ( " file:///dev/null " ) . unwrap ( ) ) )
} ) ;
2023-11-23 18:38:07 -05:00
let initial_cwd =
std ::env ::current_dir ( ) . with_context ( | | " Failed getting cwd. " ) ? ;
let directory_path = initial_cwd . join ( publish_flags . directory ) ;
// TODO: doesn't handle jsonc
let deno_json_path = directory_path . join ( " deno.json " ) ;
let deno_json = ConfigFile ::read ( & deno_json_path ) . with_context ( | | {
format! (
" Failed to read deno.json file at {} " ,
deno_json_path . display ( )
)
} ) ? ;
let mut packages =
Vec ::with_capacity ( std ::cmp ::max ( 1 , deno_json . json . workspaces . len ( ) ) ) ;
let members = & deno_json . json . workspaces ;
if members . is_empty ( ) {
2023-11-29 08:59:30 -05:00
packages
. push ( prepare_publish ( & initial_cwd , directory_path , & import_map ) . await ? ) ;
2023-11-23 18:38:07 -05:00
} else {
println! ( " Publishing a workspace... " ) ;
for member in members {
let member_dir = directory_path . join ( member ) ;
2023-11-29 08:59:30 -05:00
packages
. push ( prepare_publish ( & initial_cwd , member_dir , & import_map ) . await ? ) ;
2023-11-23 18:38:07 -05:00
}
}
if packages . is_empty ( ) {
bail! ( " No packages to publish " ) ;
}
perform_publish ( cli_factory . http_client ( ) , packages , auth_method ) . await
}