├── .npmrc ├── .editorconfig ├── package.json ├── LICENSE ├── .github └── workflows │ └── main.yml ├── .gitignore ├── CHANGELOG.md ├── README.md └── cli.js /.npmrc: -------------------------------------------------------------------------------- 1 | save-exact = true 2 | save-prefix = "" -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | indent_style = space 5 | indent_size = 2 6 | charset = utf-8 7 | trim_trailing_whitespace = true 8 | end_of_line = lf 9 | insert_final_newline = true 10 | 11 | [*.md] 12 | trim_trailing_whitespace = false 13 | 14 | [Makefile] 15 | indent_style = tab 16 | indent_size = 4 -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@ietf-tools/pypi-publish", 3 | "version": "1.1.1", 4 | "description": "Tool for publishing a Python package to PyPI from a GitHub Release", 5 | "main": "cli.js", 6 | "repository": "https://github.com/ietf-tools/pypi-publish.git", 7 | "author": "IETF Trust", 8 | "license": "BSD-3-Clause", 9 | "private": false, 10 | "dependencies": { 11 | "chalk": "5.0.0", 12 | "clipboardy": "3.0.0", 13 | "fs-extra": "10.0.0", 14 | "got": "12.0.1", 15 | "inquirer": "8.2.0", 16 | "inquirer-search-list": "1.2.6", 17 | "octokit": "1.7.1", 18 | "open": "8.4.0", 19 | "ora": "6.0.1", 20 | "yargs": "17.3.1" 21 | }, 22 | "bin": { 23 | "pypi-publish": "./cli.js" 24 | }, 25 | "engines": { 26 | "node": ">=16" 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | BSD 3-Clause License 2 | 3 | Copyright (c) 2022, ietf-tools 4 | All rights reserved. 5 | 6 | Redistribution and use in source and binary forms, with or without 7 | modification, are permitted provided that the following conditions are met: 8 | 9 | 1. Redistributions of source code must retain the above copyright notice, this 10 | list of conditions and the following disclaimer. 11 | 12 | 2. Redistributions in binary form must reproduce the above copyright notice, 13 | this list of conditions and the following disclaimer in the documentation 14 | and/or other materials provided with the distribution. 15 | 16 | 3. Neither the name of the copyright holder nor the names of its 17 | contributors may be used to endorse or promote products derived from 18 | this software without specific prior written permission. 19 | 20 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 21 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 22 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 23 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 24 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 25 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 26 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 27 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 28 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 29 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 30 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: Publish to NPM 2 | 3 | on: 4 | push: 5 | tags: 6 | - 'v*' 7 | 8 | workflow_dispatch: 9 | 10 | jobs: 11 | build: 12 | runs-on: ubuntu-latest 13 | 14 | steps: 15 | - uses: actions/checkout@v2 16 | with: 17 | fetch-depth: 0 18 | 19 | - name: Set Build Variables 20 | run: | 21 | echo "PKG_VERSION_STRICT=${GITHUB_REF_NAME#?}" >> $GITHUB_ENV 22 | 23 | - name: Setup Node.js environment 24 | uses: actions/setup-node@v2.5.1 25 | with: 26 | node-version: 16.x 27 | registry-url: https://registry.npmjs.org/ 28 | 29 | - name: Set package.json version 30 | uses: HarmvZ/set-package-json-version-action@v0.1.2 31 | with: 32 | version: ${{ env.PKG_VERSION_STRICT }} 33 | 34 | - name: Install NPM Dependencies 35 | run: npm ci 36 | 37 | - name: Publish to NPM 38 | run: npm publish --access public 39 | env: 40 | NODE_AUTH_TOKEN: ${{secrets.NPM_TOKEN}} 41 | 42 | - name: Update CHANGELOG 43 | id: changelog 44 | uses: Requarks/changelog-action@v1 45 | with: 46 | token: ${{ github.token }} 47 | tag: ${{ github.ref_name }} 48 | 49 | - name: Commit CHANGELOG.md + package.json 50 | uses: stefanzweifel/git-auto-commit-action@v4 51 | with: 52 | branch: main 53 | commit_message: 'docs: update package.json and CHANGELOG.md for ${{ github.ref_name }} [skip ci]' 54 | file_pattern: CHANGELOG.md package.json 55 | 56 | - name: Create Release 57 | uses: ncipollo/release-action@v1 58 | with: 59 | allowUpdates: true 60 | draft: false 61 | name: ${{ github.ref_name }} 62 | body: ${{ steps.changelog.outputs.changes }} 63 | token: ${{ secrets.GITHUB_TOKEN }} 64 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | lerna-debug.log* 8 | 9 | # Diagnostic reports (https://nodejs.org/api/report.html) 10 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 11 | 12 | # Runtime data 13 | pids 14 | *.pid 15 | *.seed 16 | *.pid.lock 17 | 18 | # Directory for instrumented libs generated by jscoverage/JSCover 19 | lib-cov 20 | 21 | # Coverage directory used by tools like istanbul 22 | coverage 23 | *.lcov 24 | 25 | # nyc test coverage 26 | .nyc_output 27 | 28 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 29 | .grunt 30 | 31 | # Bower dependency directory (https://bower.io/) 32 | bower_components 33 | 34 | # node-waf configuration 35 | .lock-wscript 36 | 37 | # Compiled binary addons (https://nodejs.org/api/addons.html) 38 | build/Release 39 | 40 | # Dependency directories 41 | node_modules/ 42 | jspm_packages/ 43 | 44 | # TypeScript v1 declaration files 45 | typings/ 46 | 47 | # TypeScript cache 48 | *.tsbuildinfo 49 | 50 | # Optional npm cache directory 51 | .npm 52 | 53 | # Optional eslint cache 54 | .eslintcache 55 | 56 | # Microbundle cache 57 | .rpt2_cache/ 58 | .rts2_cache_cjs/ 59 | .rts2_cache_es/ 60 | .rts2_cache_umd/ 61 | 62 | # Optional REPL history 63 | .node_repl_history 64 | 65 | # Output of 'npm pack' 66 | *.tgz 67 | 68 | # Yarn Integrity file 69 | .yarn-integrity 70 | 71 | # dotenv environment variables file 72 | .env 73 | .env.test 74 | 75 | # parcel-bundler cache (https://parceljs.org/) 76 | .cache 77 | 78 | # Next.js build output 79 | .next 80 | 81 | # Nuxt.js build / generate output 82 | .nuxt 83 | dist 84 | 85 | # Gatsby files 86 | .cache/ 87 | # Comment in the public line in if your project uses Gatsby and *not* Next.js 88 | # https://nextjs.org/blog/next-9-1#public-directory-support 89 | # public 90 | 91 | # vuepress build output 92 | .vuepress/dist 93 | 94 | # Serverless directories 95 | .serverless/ 96 | 97 | # FuseBox cache 98 | .fusebox/ 99 | 100 | # DynamoDB Local files 101 | .dynamodb/ 102 | 103 | # TernJS port file 104 | .tern-port 105 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | All notable changes to this project will be documented in this file. 3 | 4 | The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), 5 | and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). 6 | 7 | ## [v1.1.0] - 2022-08-29 8 | ### :sparkles: New Features 9 | - [`a020065`](https://github.com/ietf-tools/pypi-publish/commit/a020065d46cd15a4db28c06abe013448b7b6b4d4) - use API token for pypi publish instead of user/pass *(commit by [@NGPixel](https://github.com/NGPixel))* 10 | 11 | 12 | ## [v1.0.7] - 2022-02-03 13 | ### Bug Fixes 14 | - [`59b394ae06`](https://github.com/ietf-tools/pypi-publish/commit/59b394ae06696c0f0e6ca4b508692cebb931a058) - clean temp dir before and after publish 15 | 16 | 17 | ## [v1.0.6] - 2022-02-03 18 | ### New Features 19 | - [`efe26f8723`](https://github.com/ietf-tools/pypi-publish/commit/efe26f8723323d24363a87c6b3d24693d8bfb12b) - check version on pypi before publish 20 | 21 | 22 | ## [v1.0.5] - 2022-02-02 23 | ### New Features 24 | - [`e13fb19e8b`](https://github.com/ietf-tools/pypi-publish/commit/e13fb19e8b0fa52c8c651c269cb06b28c3509f93) - add command arguments support 25 | 26 | 27 | ## [v1.0.4] - 2022-02-02 28 | ### New Features 29 | - [`9f4449896c`](https://github.com/ietf-tools/pypi-publish/commit/9f4449896c22630580f751d80025ec52b4b61a87) - add gpg identity prompt 30 | 31 | 32 | ## [v1.0.3] - 2022-02-02 33 | ### Bug Fixes 34 | - [`6c9ac927e0`](https://github.com/ietf-tools/pypi-publish/commit/6c9ac927e0cbd8b978bec3ba6f4f5dc7a4e0ff29) - ask for target pypi before credentials 35 | 36 | 37 | ## [v1.0.2] - 2022-02-01 38 | ### Bug Fixes 39 | - [`e583acb93a`](https://github.com/ietf-tools/pypi-publish/commit/e583acb93a3d919b21621707df6eb9cf2eee76b1) - gh auth additional info + extra input validation 40 | 41 | 42 | ## [v1.0.1] - 2022-02-01 43 | ### Bug Fixes 44 | - [`924020d6a3`](https://github.com/ietf-tools/pypi-publish/commit/924020d6a33c194206b9b769a4279e23efca719e) - enforce node 16 or later engine 45 | 46 | [v1.0.1]: https://github.com/ietf-tools/pypi-publish/compare/v1.0.0...v1.0.1 47 | [v1.0.2]: https://github.com/ietf-tools/pypi-publish/compare/v1.0.1...v1.0.2 48 | [v1.0.3]: https://github.com/ietf-tools/pypi-publish/compare/v1.0.2...v1.0.3 49 | [v1.0.4]: https://github.com/ietf-tools/pypi-publish/compare/v1.0.3...v1.0.4 50 | [v1.0.5]: https://github.com/ietf-tools/pypi-publish/compare/v1.0.4...v1.0.5 51 | [v1.0.6]: https://github.com/ietf-tools/pypi-publish/compare/v1.0.5...v1.0.6 52 | [v1.0.7]: https://github.com/ietf-tools/pypi-publish/compare/v1.0.6...v1.0.7 53 | 54 | [v1.1.0]: https://github.com/ietf-tools/pypi-publish/compare/v1.0.7...v1.1.0 -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | NOTE: This project has been archived in favour of [PyPI Trusted Publishing](https://docs.pypi.org/trusted-publishers/). 2 | 3 |
4 | 5 | PYPI PUBLISH 6 | 7 | [![Release](https://img.shields.io/github/release/ietf-tools/pypi-publish.svg?style=flat&maxAge=600)](https://github.com/ietf-tools/pypi-publish/releases) 8 | [![License](https://img.shields.io/github/license/ietf-tools/pypi-publish)](https://github.com/ietf-tools/pypi-publish/blob/main/LICENSE) 9 | [![npm](https://img.shields.io/npm/v/@ietf-tools/pypi-publish)](https://www.npmjs.com/package/@ietf-tools/pypi-publish) 10 | [![node-current](https://img.shields.io/node/v/@ietf-tools/pypi-publish)](https://github.com/ietf-tools/pypi-publish) 11 | 12 | ##### Tool for publishing a Python package to PyPI from a GitHub Release 13 | 14 |
15 | 16 | - [Changelog](https://github.com/ietf-tools/pypi-publish/blob/main/CHANGELOG.md) 17 | - [Contributing](https://github.com/ietf-tools/.github/blob/main/CONTRIBUTING.md) 18 | - [Requirements](#requirements) 19 | - [Usage](#usage) 20 | 21 | --- 22 | 23 | This tool is a CLI which provides the following automation: 24 | 25 | - Fetch the list of available repositories and releases 26 | - Download the latest build of a Python package 27 | - Install Twine *(if not already installed)* 28 | - Sign and publish the package to PyPI (or TestPyPI) 29 | 30 | ## Requirements 31 | 32 | - [Node.js](https://nodejs.org/) **16.x or later** 33 | - [Python](https://www.python.org/) **3.x** 34 | 35 | > This tool assumes that you have the signing key used to sign Python packages already configured on your system. It will be used when publishing the package to PyPI. 36 | 37 | ## Usage 38 | 39 | Install the `@ietf-tools/pypi-publish` NPM package globally using: 40 | 41 | ```sh 42 | npm install -g @ietf-tools/pypi-publish 43 | ``` 44 | 45 | Then run *(from any location)*: 46 | 47 | ```sh 48 | pypi-publish 49 | ``` 50 | 51 | Enter the necessary info as prompted. 52 | 53 | ### CLI Arguments *(optional)* 54 | 55 | These arguments can also be passed to the CLI to automate values and bypass the questions. All arguments are optional. 56 | 57 | | Short | Long | Description | 58 | |---------------|-----------------------|---------------------------------------------| 59 | | `-t TARGET` | `--target=TARGET` | Target PyPI repository [`pypi`, `testpypi`] | 60 | | `-a TOKEN` | `--token=TOKEN` | PyPI API Token | 61 | | `-i IDENTITY` | `--identity=IDENTITY` | GPG identity to use for package signing | 62 | | `-p PROJECT` | `--project=PROJECT` | GitHub project (repository) to publish from | 63 | | `-r RELEASE` | `--release=RELEASE` | GitHub release to publish | 64 | | | `--python-path=PATH` | Path to Python executable | 65 | | `-h` | `--help` | Display usage + help message and exit | 66 | | `-v` | `--version` | Display CLI version and exit | 67 | 68 | ## License 69 | 70 | BSD-3-Clause 71 | -------------------------------------------------------------------------------- /cli.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | const { Octokit } = require('octokit') 4 | const { createOAuthDeviceAuth } = require('@octokit/auth-oauth-device') 5 | const inquirer = require('inquirer') 6 | const open = require('open') 7 | const fs = require('fs-extra') 8 | const os = require('os') 9 | const path = require('path') 10 | const pipeline = require('stream/promises').pipeline 11 | const spawn = require('child_process').spawn 12 | const org = 'ietf-tools' 13 | 14 | inquirer.registerPrompt('search-list', require('inquirer-search-list')) 15 | 16 | async function main () { 17 | const argv = require('yargs') 18 | .scriptName('pypi-publish') 19 | .usage('$0 [args]') 20 | .options({ 21 | 't': { 22 | alias: 'target', 23 | describe: 'Target PyPI repository', 24 | choices: ['pypi', 'testpypi'], 25 | type: 'string' 26 | }, 27 | 'a': { 28 | alias: 'token', 29 | describe: 'PyPI API Token', 30 | type: 'string' 31 | }, 32 | 'i': { 33 | alias: 'identity', 34 | describe: 'GPG identity to use for package signing', 35 | type: 'string' 36 | }, 37 | 'p': { 38 | alias: 'project', 39 | describe: 'GitHub project (repository) to publish from', 40 | type: 'string' 41 | }, 42 | 'r': { 43 | alias: 'release', 44 | describe: 'GitHub release to publish', 45 | type: 'string' 46 | }, 47 | 'python-path': { 48 | describe: 'Path to Python executable', 49 | type: 'string' 50 | } 51 | }) 52 | .help() 53 | .alias('h', 'help') 54 | .alias('v', 'version') 55 | .epilogue('All arguments are optional and will be prompted if not provided.') 56 | .argv 57 | 58 | console.info('===========================') 59 | console.info('IETF Python Publishing Tool') 60 | console.info('===========================\n') 61 | 62 | const ora = (await import('ora')).default 63 | const clipboardy = (await import('clipboardy')).default 64 | const got = (await import('got')).default 65 | const chalk = (await import('chalk')).default 66 | 67 | const optsPrompt = await inquirer.prompt([ 68 | { 69 | type: 'input', 70 | name: 'python', 71 | message: 'Enter path to Python executable:', 72 | default: process.env.PYTHONHOME || process.env.PYTHON || '', 73 | validate (v) { 74 | return (v && v.length > 1) || 'Enter a valid Python path' 75 | } 76 | }, 77 | { 78 | type: 'list', 79 | name: 'pypi', 80 | message: 'Select the target PyPI repository:', 81 | default: 0, 82 | choices: ['pypi', 'testpypi'] 83 | }, 84 | { 85 | type: 'password', 86 | name: 'token', 87 | message: 'Enter your PyPI API token:', 88 | validate (v) { 89 | return (v && v.length > 3 && v.startsWith('pypi-')) || 'Enter a PyPI API token' 90 | } 91 | }, 92 | { 93 | type: 'input', 94 | name: 'gpgidentity', 95 | message: 'Enter the GPG identity to use for signing (leave empty for default):' 96 | } 97 | ], { 98 | ...argv.pythonPath && { python: argv.pythonPath }, 99 | ...argv.t && { pypi: argv.t }, 100 | ...argv.a && { token: argv.a }, 101 | ...argv.i && { gpgidentity: argv.i } 102 | }) 103 | if (!optsPrompt?.python) { 104 | console.error(chalk.redBright('No Python path entered. Exiting...')) 105 | process.exit(1) 106 | } 107 | 108 | const spinnerAuth = ora('Waiting for GitHub authentication to complete...') 109 | 110 | const gh = new Octokit({ 111 | userAgent: 'ietf-pypi-publish', 112 | authStrategy: createOAuthDeviceAuth, 113 | auth: { 114 | clientId: 'e9642b43d2c36ba005b8', 115 | clientType: 'oauth-app', 116 | scopes: ['public_repo'], 117 | onVerification(verif) { 118 | console.info(` 119 | Open in your browser: ${chalk.underline.green(verif.verification_uri)} 120 | Enter code: ${chalk.bold.yellowBright(verif.user_code)} 121 | 122 | ${chalk.italic.grey('(The code has already been copied to your clipboard for convenience.)')} 123 | `) 124 | spinnerAuth.start() 125 | try { 126 | clipboardy.writeSync(verif.user_code) 127 | open(verif.verification_uri) 128 | } catch (err) {} 129 | } 130 | } 131 | }) 132 | 133 | await gh.auth({ type: 'oauth' }) 134 | spinnerAuth.succeed('Authenticated to GitHub.') 135 | 136 | // -> Fetch GitHub Repos 137 | 138 | const spinnerFetchRepos = ora('Fetching list of GitHub repositories...').start() 139 | let repos = [] 140 | try { 141 | const reposRaw = await gh.rest.repos.listForOrg({ 142 | org: org, 143 | type: 'public', 144 | sort: 'updated', 145 | direction: 'desc', 146 | per_page: 100 147 | }) 148 | repos = reposRaw?.data?.filter(r => !r.archived && !r.disabled && r.name !== '.github').map(r => r.name).sort() ?? [] 149 | } catch (err) { 150 | spinnerFetchRepos.fail('Failed to fetch list of GitHub repositories!') 151 | console.error(chalk.redBright(err.message)) 152 | process.exit(1) 153 | } 154 | spinnerFetchRepos.succeed(`Fetched ${repos.length} most recently updated GitHub repositories.`) 155 | 156 | // -> Select GitHub Repo to use 157 | 158 | let repo = null 159 | if (argv.p) { 160 | if (repos.includes(argv.p)) { 161 | repo = argv.p 162 | ora(`Using GitHub repository: ${repo}`).succeed() 163 | } else { 164 | console.warn(chalk.redBright('Invalid GitHub repository provided.')) 165 | } 166 | } 167 | 168 | if (!repo) { 169 | let repoPrompt = await inquirer.prompt([ 170 | { 171 | type: 'search-list', 172 | name: 'repo', 173 | message: 'Select the GitHub repository to use:', 174 | choices: repos 175 | } 176 | ]) 177 | if (!repoPrompt?.repo) { 178 | console.error(chalk.redBright('Invalid or no repository selected. Exiting...')) 179 | process.exit(1) 180 | } 181 | repo = repoPrompt.repo 182 | } 183 | 184 | // -> Fetch GitHub releases 185 | 186 | const spinnerFetchReleases = ora('Fetching list of GitHub repositories...').start() 187 | let releases = [] 188 | try { 189 | const releasesRaw = await gh.graphql(` 190 | query lastReleases ($owner: String!, $repo: String!) { 191 | repository (owner: $owner, name: $repo) { 192 | releases(first: 10, orderBy: { field: CREATED_AT, direction: DESC }) { 193 | nodes { 194 | author { 195 | login 196 | } 197 | createdAt 198 | id 199 | name 200 | releaseAssets (first: 100) { 201 | nodes { 202 | downloadUrl 203 | name 204 | size 205 | id 206 | url 207 | } 208 | } 209 | tag { 210 | name 211 | } 212 | url 213 | isDraft 214 | isLatest 215 | isPrerelease 216 | } 217 | } 218 | } 219 | } 220 | `, { 221 | owner: org, 222 | repo: repo 223 | }) 224 | releases = releasesRaw?.repository?.releases?.nodes ?? [] 225 | } catch (err) { 226 | spinnerFetchReleases.fail('Failed to fetch list of releases!') 227 | console.error(chalk.redBright(err.message)) 228 | process.exit(1) 229 | } 230 | if (releases.length > 0) { 231 | spinnerFetchReleases.succeed(`Fetched ${releases.length} most recent releases.`) 232 | } else { 233 | spinnerFetchReleases.fail('This project has no release! Exiting...') 234 | process.exit(1) 235 | } 236 | 237 | // -> Select release to use 238 | 239 | let releaseName = null 240 | if (argv.r) { 241 | if (releases.map(r => r.name).includes(argv.r)) { 242 | releaseName = argv.r 243 | ora(`Using GitHub release: ${releaseName}`).succeed() 244 | } else { 245 | console.warn(chalk.redBright('Invalid GitHub release provided.')) 246 | } 247 | } 248 | 249 | if (!releaseName) { 250 | const releasePrompt = await inquirer.prompt([ 251 | { 252 | type: 'list', 253 | name: 'release', 254 | message: 'Select the release to publish:', 255 | choices: releases.map(r => r.name), 256 | default: 0 257 | } 258 | ]) 259 | if (!releasePrompt?.release) { 260 | console.error(chalk.redBright('Invalid or no release selected. Exiting...')) 261 | process.exit(1) 262 | } 263 | releaseName = releasePrompt.release 264 | } 265 | const release = releases.filter(r => r.name === releaseName)[0] 266 | 267 | // -> Check for python dist packages 268 | 269 | if (release.releaseAssets.nodes.map(a => a.name).filter(a => a.endsWith('.tar.gz')).length < 1) { 270 | console.error(chalk.redBright('Could not find any Python distribution type asset. Make sure the release has a build attached. Exiting...')) 271 | process.exit(1) 272 | } 273 | 274 | // -> Check for existing version on PyPI 275 | 276 | const spinnerCheckExistingVer = ora('Checking for existing version on PyPI...').start() 277 | const pypiHost = optsPrompt.pypi === 'pypi' ? 'pypi.org' : 'test.pypi.org' 278 | try { 279 | await got({ 280 | url: `https://${pypiHost}/pypi/${repo}/${release.name}/json` 281 | }).json() 282 | spinnerCheckExistingVer.fail(`Version ${release.name} already exists on ${pypiHost}. Cannot overwrite an existing version! Exiting...`) 283 | process.exit(1) 284 | } catch (err) { 285 | spinnerCheckExistingVer.succeed(`Version ${release.name} does not exist yet on ${pypiHost}.`) 286 | } 287 | 288 | // -> Create temp dir 289 | 290 | const spinnerCreateDir = ora('Downloading release assets...').start() 291 | let tempdir = null 292 | let distdir = null 293 | try { 294 | tempdir = path.join(os.tmpdir(), 'ietf-pypi-publish') 295 | distdir = path.join(tempdir, 'dist') 296 | await fs.emptyDir(tempdir) 297 | await fs.ensureDir(distdir) 298 | spinnerCreateDir.succeed(`Created temp directory: ${distdir}`) 299 | } catch (err) { 300 | spinnerCreateDir.fail('Failed to create temp directory.') 301 | console.error(chalk.redBright(err.message)) 302 | process.exit(1) 303 | } 304 | 305 | // -> Download release assets 306 | 307 | const spinnerDownloadAssets = ora({ text: 'Downloading release assets...', spinner: 'arrow3' }).start() 308 | let assetDownloaded = 0 309 | for (const asset of release.releaseAssets.nodes) { 310 | spinnerDownloadAssets.text = `Downloading asset ${asset.name}...` 311 | try { 312 | await pipeline( 313 | got.stream(asset.url), 314 | fs.createWriteStream(path.join(distdir, asset.name)) 315 | ) 316 | assetDownloaded++ 317 | } catch (err) { 318 | spinnerCreateDir.fail(`Failed to download asset ${asset.name}.`) 319 | console.error(chalk.redBright(err.message)) 320 | process.exit(1) 321 | } 322 | } 323 | spinnerDownloadAssets.succeed(`Downloaded ${assetDownloaded} assets.`) 324 | 325 | // -> Install Twine 326 | 327 | const spinnerInstallTwine = ora('Installing Twine...').start() 328 | const errorsInstall = [] 329 | try { 330 | const proc = spawn(optsPrompt?.python, ['-m', 'pip', 'install', 'twine'], { 331 | cwd: tempdir, 332 | windowsHide: true, 333 | timeout: 1000 * 60 * 5 334 | }) 335 | proc.stderr.on('data', data => { 336 | errorsInstall.push(data.toString('utf8')) 337 | }) 338 | await new Promise((resolve, reject) => { 339 | proc.on('exit', code => { 340 | if (code > 0) { 341 | reject(new Error(errorsInstall.join(', '))) 342 | } else { 343 | resolve() 344 | } 345 | }) 346 | }) 347 | } catch (err) { 348 | spinnerInstallTwine.fail('Failed to install Twine.') 349 | console.error(chalk.redBright(err.message)) 350 | process.exit(1) 351 | } 352 | spinnerInstallTwine.succeed('Installed Twine successfully.') 353 | 354 | // -> Last prompt check before publishing... 355 | 356 | const confirmPrompt = await inquirer.prompt([ 357 | { 358 | type: 'confirm', 359 | name: 'go', 360 | message: `Proceed with publishing package ${repo}: ${release.name} to ${optsPrompt.pypi}?`, 361 | default: false 362 | } 363 | ]) 364 | if (!confirmPrompt?.go) { 365 | console.error(chalk.redBright('Publishing aborted by the user. Exiting...')) 366 | process.exit(1) 367 | } 368 | 369 | // -> Run Twine 370 | 371 | const spinnerRunTwine = ora('Publishing package using Twine...').start() 372 | const errorsRun = [] 373 | try { 374 | const twineParams = ['-m', 'twine', 'upload', '--verbose', '--sign'] 375 | if (optsPrompt.gpgidentity) { 376 | twineParams.push('--identity') 377 | twineParams.push(optsPrompt.gpgidentity) 378 | } 379 | twineParams.push('dist/*') 380 | const proc = spawn(optsPrompt.python, twineParams, { 381 | cwd: tempdir, 382 | windowsHide: true, 383 | timeout: 1000 * 60 * 5, 384 | env: { 385 | ...process.env, 386 | TWINE_USERNAME: '__token__', 387 | TWINE_PASSWORD: optsPrompt.token, 388 | TWINE_REPOSITORY_URL: optsPrompt.pypi === 'pypi' ? 'https://upload.pypi.org/legacy/' : 'https://test.pypi.org/legacy/' 389 | } 390 | }) 391 | proc.stderr.on('data', data => { 392 | errorsRun.push(data.toString('utf8')) 393 | }) 394 | await new Promise((resolve, reject) => { 395 | proc.on('exit', code => { 396 | if (code > 0) { 397 | reject(new Error(errorsRun.join(', '))) 398 | } else { 399 | resolve() 400 | } 401 | }) 402 | }) 403 | } catch (err) { 404 | spinnerRunTwine.fail('Failed to publish package.') 405 | console.error(chalk.redBright(err.message)) 406 | process.exit(1) 407 | } 408 | spinnerRunTwine.succeed('Published package successfully.') 409 | 410 | // -> Clean up temp directory 411 | 412 | try { 413 | await fs.emptyDir(tempdir) 414 | } catch (err) { 415 | console.error(chalk.yellow(`Unable to clean temp folder ${tempdir}`)) 416 | } 417 | 418 | process.exit(0) 419 | } 420 | 421 | main() 422 | --------------------------------------------------------------------------------