Skip to content

Commit eefa5bd

Browse files
balzssclaude
andcommitted
feat(ui-scripts): add publish-private command for private-registry releases
Adds a new `ui-scripts publish-private` command that publishes all non-private packages to a private npm-compatible registry. Intended for distributing fixes to a small set of consumers before a public release. The command reads INSTUI_PRIVATE_REGISTRY and INSTUI_PRIVATE_REGISTRY_TOKEN from the environment, refuses any npmjs.org host as a hard guard, prompts the operator to confirm the registry hostname (skippable with --yes), and writes an isolated .npmrc to a temp dir via NPM_CONFIG_USERCONFIG so the user's global config is left untouched. Each package is published under the `security` dist-tag with `--no-git-checks` and no `--provenance`. The existing `publish` command, npm.js helpers, and release workflows are unchanged. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
1 parent eaf8876 commit eefa5bd

4 files changed

Lines changed: 206 additions & 0 deletions

File tree

.env.example

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
# Private-registry publishing (`pnpm run publish-private`).
2+
# Both must be set; the command exits 1 if either is missing.
3+
INSTUI_PRIVATE_REGISTRY=https://<host>/<path>/
4+
INSTUI_PRIVATE_REGISTRY_TOKEN=

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@
3939
"create-component-version": "ui-scripts create-component-version",
4040
"bump": "ui-scripts bump",
4141
"release": "ui-scripts publish",
42+
"publish-private": "ui-scripts publish-private",
4243
"husky:pre-commit": "lint-staged && node scripts/checkTSReferences.js",
4344
"postinstall": "husky",
4445
"ts:check": "pnpm -r --stream ts:check"

packages/ui-scripts/lib/commands/index.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ import server from './server.js'
2727
import tag from './tag.js'
2828
import deprecate from './deprecate.js'
2929
import publish from './publish.js'
30+
import publishPrivate from './publish-private.js'
3031
import visualDiff from './visual-diff.ts'
3132
import lint from '../test/lint.js'
3233
import bundle from '../build/webpack.js'
@@ -43,6 +44,7 @@ export const yargCommands = [
4344
tag,
4445
deprecate,
4546
publish,
47+
publishPrivate,
4648
visualDiff,
4749
lint,
4850
bundle,
Lines changed: 199 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,199 @@
1+
/*
2+
* The MIT License (MIT)
3+
*
4+
* Copyright (c) 2015 - present Instructure, Inc.
5+
*
6+
* Permission is hereby granted, free of charge, to any person obtaining a copy
7+
* of this software and associated documentation files (the "Software"), to deal
8+
* in the Software without restriction, including without limitation the rights
9+
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
10+
* copies of the Software, and to permit persons to whom the Software is
11+
* furnished to do so, subject to the following conditions:
12+
*
13+
* The above copyright notice and this permission notice shall be included in all
14+
* copies or substantial portions of the Software.
15+
*
16+
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
17+
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
18+
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
19+
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
20+
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
21+
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
22+
* SOFTWARE.
23+
*/
24+
25+
import os from 'node:os'
26+
import path from 'node:path'
27+
import fs from 'node:fs'
28+
import readline from 'node:readline/promises'
29+
30+
import pkgUtils from '@instructure/pkg-utils'
31+
import { error, info, runCommandAsync } from '@instructure/command-utils'
32+
33+
const PRIVATE_TAG = 'security'
34+
35+
export default {
36+
command: 'publish-private',
37+
desc: 'publishes ALL non-private packages to a private npm-compatible registry. Reads INSTUI_PRIVATE_REGISTRY and INSTUI_PRIVATE_REGISTRY_TOKEN from the environment.',
38+
builder: (yargs) => {
39+
yargs.option('yes', {
40+
type: 'boolean',
41+
describe: 'Skip the interactive registry-hostname confirmation prompt',
42+
default: false
43+
})
44+
},
45+
handler: async (argv) => {
46+
try {
47+
await publishPrivate({ skipConfirm: argv.yes })
48+
} catch (err) {
49+
error(err)
50+
process.exit(1)
51+
}
52+
}
53+
}
54+
55+
async function publishPrivate({ skipConfirm }) {
56+
const registry = process.env.INSTUI_PRIVATE_REGISTRY
57+
const token = process.env.INSTUI_PRIVATE_REGISTRY_TOKEN
58+
59+
if (!registry || !token) {
60+
error(
61+
'INSTUI_PRIVATE_REGISTRY and INSTUI_PRIVATE_REGISTRY_TOKEN must both be set in your environment.'
62+
)
63+
process.exit(1)
64+
}
65+
66+
let registryUrl
67+
try {
68+
registryUrl = new URL(registry)
69+
} catch {
70+
error(`INSTUI_PRIVATE_REGISTRY is not a valid URL: ${registry}`)
71+
process.exit(1)
72+
}
73+
74+
// Hard guard: this command must never publish to npmjs.
75+
if (/(^|\.)npmjs\.org$/i.test(registryUrl.hostname)) {
76+
error(
77+
`Refusing to publish-private to ${registryUrl.hostname}. Use \`pnpm run release\` for public npmjs releases.`
78+
)
79+
process.exit(1)
80+
}
81+
82+
const packages = pkgUtils.getPackages().filter((pkg) => !pkg.private)
83+
if (packages.length === 0) {
84+
error('No publishable (non-private) packages found.')
85+
process.exit(1)
86+
}
87+
88+
const sampleVersion = packages[0].version
89+
90+
info('')
91+
info('📦 Private (embargoed) publish')
92+
info(` Registry: ${registryUrl.hostname}`)
93+
info(` Tag: ${PRIVATE_TAG}`)
94+
info(` Packages: ${packages.length} packages at version ${sampleVersion}`)
95+
info('')
96+
97+
if (!skipConfirm) {
98+
await confirmHostname(registryUrl.hostname)
99+
}
100+
101+
const npmrcPath = writeTempNpmrc(registryUrl, token)
102+
103+
try {
104+
for (let i = 0; i < packages.length; i++) {
105+
const pkg = packages[i]
106+
await publishPackage(pkg, npmrcPath)
107+
info(
108+
`📦 ${pkg.name}@${pkg.version} published to ${registryUrl.hostname}`
109+
)
110+
// Throttle to avoid registry rate-limiting on bulk publishes.
111+
if (i < packages.length - 1) {
112+
await new Promise((resolve) => setTimeout(resolve, 500))
113+
}
114+
}
115+
} finally {
116+
cleanupTempNpmrc(npmrcPath)
117+
}
118+
119+
info('')
120+
info(
121+
`✅ Done. ${packages.length} packages published to ${registryUrl.hostname} under tag "${PRIVATE_TAG}".`
122+
)
123+
}
124+
125+
async function confirmHostname(hostname) {
126+
const rl = readline.createInterface({
127+
input: process.stdin,
128+
output: process.stdout
129+
})
130+
try {
131+
const answer = await rl.question(
132+
`Type the registry hostname to confirm (${hostname}): `
133+
)
134+
if (answer.trim() !== hostname) {
135+
error('Confirmation did not match. Aborting.')
136+
process.exit(1)
137+
}
138+
} finally {
139+
rl.close()
140+
}
141+
}
142+
143+
function writeTempNpmrc(registryUrl, token) {
144+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'instui-publish-private-'))
145+
const file = path.join(dir, '.npmrc')
146+
const authPath = registryUrl.pathname.endsWith('/')
147+
? registryUrl.pathname
148+
: `${registryUrl.pathname}/`
149+
const authLine = `//${registryUrl.host}${authPath}:_authToken=${token}`
150+
const registryLine = `registry=${registryUrl.toString()}`
151+
fs.writeFileSync(file, `${registryLine}\n${authLine}\n`, { mode: 0o600 })
152+
return file
153+
}
154+
155+
function cleanupTempNpmrc(npmrcPath) {
156+
try {
157+
fs.rmSync(path.dirname(npmrcPath), { recursive: true, force: true })
158+
} catch {
159+
// best effort — temp dir cleanup failure is not fatal
160+
}
161+
}
162+
163+
async function publishPackage(pkg, npmrcPath) {
164+
const childEnv = { NPM_CONFIG_USERCONFIG: npmrcPath }
165+
166+
// Skip if this version is already on the private registry.
167+
let versions = []
168+
try {
169+
const { stdout } = await runCommandAsync(
170+
'pnpm',
171+
['info', pkg.name, '--json'],
172+
childEnv,
173+
{ stdio: 'pipe' }
174+
)
175+
versions = JSON.parse(stdout).versions || []
176+
} catch {
177+
// Package not yet in the registry — fall through and publish.
178+
}
179+
180+
if (versions.includes(pkg.version)) {
181+
throw new Error(
182+
`📦 ${pkg.name}@${pkg.version} is already published to the private registry.`
183+
)
184+
}
185+
186+
await runCommandAsync(
187+
'pnpm',
188+
[
189+
'publish',
190+
pkg.location,
191+
'--tag',
192+
PRIVATE_TAG,
193+
'--no-git-checks'
194+
// Provenance is npmjs-only; omitted for non-npmjs registries.
195+
// Registry + auth come from the temp .npmrc via NPM_CONFIG_USERCONFIG.
196+
],
197+
childEnv
198+
)
199+
}

0 commit comments

Comments
 (0)