From ac8840605d90458e279f1b6c65cdc7b6208dcd0c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bal=C3=A1zs=20S=C3=A1ros?= Date: Tue, 12 May 2026 15:16:16 +0200 Subject: [PATCH] feat(ui-scripts): allow pr-snapshot to publish at an operator-supplied prerelease version MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds two optional inputs to the pr-snapshot workflow_dispatch path: custom_version (e.g. 11.7.3-SECURITY.0) and dist_tag (e.g. security). When set, they override the auto-computed snapshot version and the default pr-snapshot dist-tag. Use case: mirror a previously-published private security release onto the public registry under a non-latest dist-tag, so open-source consumers who pinned to a prerelease version from the private registry can switch their resolution to npmjs without changing package.json. Workflow plumbing: - release_to_npm.yml: two new optional inputs forwarded to the pr-release job - _pr-release-reusable.yml: accepts the inputs, validates them, and forwards as --customVersion / --distTag (via env vars to avoid shell-injection from workflow_dispatch input values) publish.js: - new --customVersion / --distTag flags - publishSnapshotVersion uses customVersion when supplied, else falls back to calculateNextSnapshotVersion as today - validateCustomVersionInputs() enforces guards: valid semver, prerelease only (refuses stable versions so we can never take over a future stable slot), distTag not 'latest', distTag required when customVersion is set Existing pr-snapshot behavior is unchanged when the new inputs are blank. OIDC auth + --provenance preserved. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- .github/workflows/_pr-release-reusable.yml | 35 +++++++- .github/workflows/release_to_npm.yml | 17 +++- packages/ui-scripts/lib/commands/publish.ts | 96 ++++++++++++++++++--- 3 files changed, 135 insertions(+), 13 deletions(-) diff --git a/.github/workflows/_pr-release-reusable.yml b/.github/workflows/_pr-release-reusable.yml index fd89aa0d2d..1b22eabdd4 100644 --- a/.github/workflows/_pr-release-reusable.yml +++ b/.github/workflows/_pr-release-reusable.yml @@ -1,6 +1,17 @@ name: PR release to npm (Reusable) on: workflow_call: + inputs: + custom_version: + description: 'Optional: exact semver prerelease version to publish, overriding the auto-computed snapshot version.' + required: false + type: string + default: '' + dist_tag: + description: 'Optional: npm dist-tag override. Required when custom_version is set. Cannot be "latest".' + required: false + type: string + default: '' permissions: id-token: write @@ -11,6 +22,20 @@ jobs: runs-on: ubuntu-latest name: Release to npm steps: + - name: Validate custom-version inputs + if: inputs.custom_version != '' + env: + CUSTOM_VERSION: ${{ inputs.custom_version }} + DIST_TAG: ${{ inputs.dist_tag }} + run: | + if [ -z "$DIST_TAG" ]; then + echo "::error::dist_tag is required when custom_version is set" + exit 1 + fi + if [ "$DIST_TAG" = "latest" ]; then + echo "::error::dist_tag cannot be 'latest'" + exit 1 + fi - uses: actions/checkout@v6 with: fetch-depth: 0 @@ -25,7 +50,15 @@ jobs: - name: Set up project run: pnpm run bootstrap - name: Release to NPM - run: pnpm run release --prRelease + env: + CUSTOM_VERSION: ${{ inputs.custom_version }} + DIST_TAG: ${{ inputs.dist_tag }} + run: | + if [ -n "$CUSTOM_VERSION" ]; then + pnpm run release --prRelease --customVersion="$CUSTOM_VERSION" --distTag="$DIST_TAG" + else + pnpm run release --prRelease + fi - name: Get commit message run: | # puts the first line of the last commit message to the commmit_message env var echo "commmit_message=$(git log --format=%B -n 1 ${{ github.event.after }} | head -n 1)" >> $GITHUB_ENV diff --git a/.github/workflows/release_to_npm.yml b/.github/workflows/release_to_npm.yml index c7b4187c4b..01670e7958 100644 --- a/.github/workflows/release_to_npm.yml +++ b/.github/workflows/release_to_npm.yml @@ -14,6 +14,16 @@ on: - manual - pr-snapshot default: 'manual' + custom_version: + description: 'Optional: exact semver prerelease version to publish (pr-snapshot only). Overrides the auto-computed snapshot version. Example: 11.7.3-SECURITY.0' + required: false + type: string + default: '' + dist_tag: + description: 'Optional: npm dist-tag override (pr-snapshot only). Required when custom_version is set. Cannot be "latest".' + required: false + type: string + default: '' permissions: id-token: write # Required for OIDC token generation @@ -38,10 +48,15 @@ jobs: contents: write secrets: inherit - # PR snapshot release + # PR snapshot release. Optionally accepts custom_version + dist_tag to mirror + # a previously-published private security release onto the public registry + # under a non-latest dist-tag. pr-release: if: github.event_name == 'workflow_dispatch' && inputs.release_type == 'pr-snapshot' uses: ./.github/workflows/_pr-release-reusable.yml + with: + custom_version: ${{ inputs.custom_version }} + dist_tag: ${{ inputs.dist_tag }} permissions: id-token: write contents: write diff --git a/packages/ui-scripts/lib/commands/publish.ts b/packages/ui-scripts/lib/commands/publish.ts index 70f0484a0d..a42f7ddabe 100644 --- a/packages/ui-scripts/lib/commands/publish.ts +++ b/packages/ui-scripts/lib/commands/publish.ts @@ -49,16 +49,32 @@ export default { describe: 'If true pnpm publish will use vXYZ-pr-snapshot as version', default: false }) + + yargs.option('customVersion', { + type: 'string', + describe: + 'Publish all packages at this exact semver prerelease version (e.g. 11.7.3-SECURITY.0) instead of the version in package.json. Must be combined with --distTag.', + default: '' + }) + + yargs.option('distTag', { + type: 'string', + describe: + 'Override the npm dist-tag used for this release. Required with --customVersion. Cannot be "latest".', + default: '' + }) }, handler: async (argv: any) => { - const { isMaintenance, prRelease } = argv + const { isMaintenance, prRelease, customVersion, distTag } = argv try { const pkgJSON = pkgUtils.getPackageJSON() await publish({ packageName: pkgJSON.name, version: pkgJSON.version, isMaintenance, - prRelease + prRelease, + customVersion, + distTag }) } catch (err) { error(err) @@ -71,15 +87,21 @@ async function publish({ packageName, version, isMaintenance, - prRelease + prRelease, + customVersion, + distTag }: { packageName: string version: string isMaintenance: boolean prRelease: boolean + customVersion: string + distTag: string }) { const isRegularRelease = isReleaseCommit(version) + validateCustomVersionInputs({ customVersion, distTag, isRegularRelease }) + checkNpmAuth() try { @@ -98,14 +120,15 @@ async function publish({ packages }) } else { - const tag = prRelease ? 'pr-snapshot' : 'snapshot' - info(`📦 Version: ${version}, Tag: ${tag}`) + const tag = distTag || (prRelease ? 'pr-snapshot' : 'snapshot') + info(`📦 Version: ${customVersion || version}, Tag: ${tag}`) await publishSnapshotVersion({ version, packageName, packages, tag, - prRelease + prRelease, + customVersion }) } } finally { @@ -113,6 +136,56 @@ async function publish({ } } +/** + * Validates the optional --customVersion / --distTag pair used to mirror a + * private security release onto the public registry under a non-latest tag. + * Throws if the inputs are inconsistent or unsafe. + */ +function validateCustomVersionInputs({ + customVersion, + distTag, + isRegularRelease +}: { + customVersion: string + distTag: string + isRegularRelease: boolean +}) { + if (!customVersion && !distTag) return + + // These inputs only affect the snapshot/prerelease path. On a release commit + // they would be silently ignored, so fail loudly instead of publishing the + // wrong version under the wrong tag. + if (isRegularRelease) { + throw new Error( + '--customVersion / --distTag are only supported for snapshot releases, but this is a release commit. Aborting to avoid silently ignoring them.' + ) + } + + if (distTag === 'latest') { + throw new Error('--distTag cannot be "latest".') + } + + if (customVersion) { + if (!semver.valid(customVersion)) { + throw new Error( + `--customVersion must be a valid semver, got: "${customVersion}"` + ) + } + if (!semver.prerelease(customVersion)) { + throw new Error( + `--customVersion must be a prerelease (e.g. 11.7.3-SECURITY.0); got "${customVersion}". Refusing to take over a stable version slot.` + ) + } + if (!distTag) { + throw new Error('--distTag is required when --customVersion is set.') + } + } else if (distTag) { + // distTag on its own would override the tag while still auto-computing the + // version — almost certainly not what the operator intended. + throw new Error('--distTag can only be used together with --customVersion.') + } +} + /** * Publishes each package to pnpm. */ @@ -124,13 +197,14 @@ async function publishRegularVersion(arg: any) { } /** - * Calculates the new snapshot version based on the latest tag - * and the current commit, then publishes each package to npm - * with the new snapshot version. + * Bumps packages to a snapshot/prerelease version (either operator-supplied + * via --customVersion, or auto-computed from the commit history) and + * publishes them under the given dist-tag. */ async function publishSnapshotVersion(arg: any) { - const { version, packageName, packages, tag, prRelease } = arg - const snapshotVersion = calculateNextSnapshotVersion(version, prRelease) + const { version, packageName, packages, tag, prRelease, customVersion } = arg + const snapshotVersion = + customVersion || calculateNextSnapshotVersion(version, prRelease) info(`applying new snapshot version (${snapshotVersion}) to each package`)