├── .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 |

6 |
7 | [](https://github.com/ietf-tools/pypi-publish/releases)
8 | [](https://github.com/ietf-tools/pypi-publish/blob/main/LICENSE)
9 | [](https://www.npmjs.com/package/@ietf-tools/pypi-publish)
10 | [](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 |
--------------------------------------------------------------------------------