Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
91 changes: 91 additions & 0 deletions .github/workflows/bundle-and-publish-cli.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
name: Bundle and Publish @ui5/cli

@d3xter666 d3xter666 Sep 3, 2026

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is an actual copy of shrinkwrap/lockfile-extractor run + package deployment to NPM from release-please.yaml

Now it has a dry run flag, so we can integrate this flow into our current CI environment. This way, we can ensure that every merge in the main branch won't break the release process.


# Reusable workflow: called by release-please.yml (real publish) and github-ci.yml (dry-run).
# The caller controls whether the publish is real or dry-run via the dry_run input.

on:
workflow_call:
inputs:
dry_run:
description: "Skip actual publish — runs npm publish --dry-run for CI verification"
type: boolean
default: false
npm_tag:
description: "npm dist-tag (e.g. next, latest)"
type: string
default: next
npm_env:
# Setting this shapes the OIDC token subject claim to
# 'repo:UI5/cli:environment:<name>', which must match the trusted
# publisher configuration on npmjs.com.
# Pass 'npmjs:ui5-cli-mono' for a real publish; leave empty for
# dry-run calls (npm publish --dry-run never contacts the registry,
# so no OIDC token is needed — and omitting the environment avoids
# deployment-protection rules that would otherwise block PR builds).
description: >
GitHub Actions environment name for OIDC trusted publishing (e.g. npmjs:ui5-cli-mono). Leave empty for dry-run calls.
type: string
default: ""

jobs:
bundle-and-publish:
runs-on: ubuntu-24.04
# Controls the OIDC subject claim: 'repo:UI5/cli:environment:<npm_env>'.
# npm's trusted publisher verifies this claim — empty string means no environment
# (subject becomes 'repo:UI5/cli:ref:refs/heads/...'), used for dry-run only.
# Permissions are not set here: they are inherited from the calling job.
# The real-publish caller (release-please.yml) passes id-token:write;
# the dry-run caller (github-ci.yml) does not, so no OIDC token is minted for dry-runs.
environment: ${{ inputs.npm_env }}
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
Comment thread
d3xter666 marked this conversation as resolved.
with:
persist-credentials: false

- name: Node.js LTS
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: 24.x

- name: Install dependencies
run: npm ci

- name: Generate package-lock.json for bundling
working-directory: packages/cli
run: |
set -e
node ../../internal/lockfile-extractor/cli.js ../../

- name: Bundle and publish @ui5/cli
env:
NPM_TAG: ${{ inputs.npm_tag }}
DRY_RUN: ${{ inputs.dry_run }}
run: |
set -e
TEMP_CLI_DIR=$(mktemp -d)

# Copy package (including generated package-lock.json) outside the workspace
cp -r packages/cli/. "$TEMP_CLI_DIR/"
echo "📦 Copied @ui5/cli to temporary directory: $TEMP_CLI_DIR"

cd "$TEMP_CLI_DIR"

# Strip devDependencies so npm ci only installs production deps matching the lock file
node -e "
const fs = require('fs');
const pkg = JSON.parse(fs.readFileSync('package.json', 'utf8'));
delete pkg.devDependencies;
fs.writeFileSync('package.json', JSON.stringify(pkg, null, '\t'));
"

# Install exact versions from generated lock file (no workspace symlinks)
echo "📦 Installing production dependencies from package-lock.json"
npm ci

if [ "$DRY_RUN" = "true" ]; then
echo "🔍 Dry-run: reporting what would be published (no actual publish)"
npm publish --access public --tag "$NPM_TAG" --dry-run
else
echo "🚀 Publishing @ui5/cli from temporary directory: $TEMP_CLI_DIR"
npm publish --access public --tag "$NPM_TAG"
fi
11 changes: 9 additions & 2 deletions .github/workflows/github-ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,13 @@ jobs:
npm run build:vitepress
npm run build:assets

- name: Check shrinkwrap integrity
working-directory: internal/shrinkwrap-extractor
- name: Run lockfile extractor tests
working-directory: internal/lockfile-extractor
run: npm run test

bundle-cli-dry-run:
name: Verify @ui5/cli bundle (dry-run)
needs: test
uses: ./.github/workflows/bundle-and-publish-cli.yml
with:
dry_run: true
34 changes: 7 additions & 27 deletions .github/workflows/release-please.yml
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,7 @@ jobs:
environment: npmjs:ui5-cli-mono
strategy:
# Sequential publishing ensures dependencies exist on NPM before dependents are published
# Order: logger → fs → builder → server → project (CLI handled separately for shrinkwrap generation)
# Order: logger → fs → builder → server → project (CLI handled separately for lockfile generation)
max-parallel: 1
matrix:
package: [logger, fs, builder, server, project]
Expand All @@ -98,7 +98,6 @@ jobs:
npm publish --access public --tag next

publish-cli:
runs-on: ubuntu-24.04
needs: [release-please, publish-packages]
# Two paths:
# 1. Automatic: release-please created releases and publish-packages succeeded
Expand All @@ -117,28 +116,9 @@ jobs:
)
)
permissions:
id-token: write # Required for trusted publishing via OIDC (https://docs.npmjs.com/trusted-publishers)
# The GitHub Actions Environment configured for the trusted publisher
environment: npmjs:ui5-cli-mono
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1

- name: Node.js LTS
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: 24.x

- name: Install dependencies
run: npm ci

- name: Generate npm-shrinkwrap.json
working-directory: packages/cli
run: |
set -e
node ../../internal/shrinkwrap-extractor/cli.js ../../

- name: Publish @ui5/cli package
working-directory: packages/cli
run: |
echo "🚀 Publishing @ui5/cli"
npm publish --access public --tag next
id-token: write # Required for OIDC trusted publishing inside the reusable workflow
uses: ./.github/workflows/bundle-and-publish-cli.yml
with:
dry_run: false
npm_tag: next
npm_env: npmjs:ui5-cli-mono
2 changes: 1 addition & 1 deletion .github/workflows/reuse-compliance.yml
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ jobs:
matrix:
package:
- "internal/documentation"
- "internal/shrinkwrap-extractor"
- "internal/lockfile-extractor"
- "packages/builder"
- "packages/cli"
- "packages/fs"
Expand Down
5 changes: 4 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -72,4 +72,7 @@ internal/documentation/docs/api
internal/documentation/tmp

# E2E-tests
internal/e2e-tests/tmp
internal/e2e-tests/tmp

# Generated during bundled CLI publish flow (not committed)
packages/cli/package-lock.json
4 changes: 2 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@ npm run coverage --workspace=@ui5/server # Single package

Internal packages:
- `internal/documentation` — VitePress docs + JSDoc + JSON schema generation
- `internal/shrinkwrap-extractor` — npm shrinkwrap utilities
- `internal/lockfile-extractor` — generates a standalone package-lock.json for @ui5/cli from the monorepo workspace lock file

### Internal package dependencies

Expand Down Expand Up @@ -93,6 +93,6 @@ Conventional commits enforced via commitlint + husky. Subject must be sentence-c

**Types**: `build`, `ci`, `deps`, `docs`, `feat`, `fix`, `perf`, `refactor`, `release`, `revert`, `style`, `test`

**Scopes** are package names: `builder`, `cli`, `documentation`, `fs`, `logger`, `project`, `server`, `shrinkwrap-extractor`. Some types restrict which scopes are valid (e.g., `feat` and `fix` only allow public package scopes).
**Scopes** are package names: `builder`, `cli`, `documentation`, `fs`, `logger`, `project`, `server`, `lockfile-extractor`. Some types restrict which scopes are valid (e.g., `feat` and `fix` only allow public package scopes).

Examples: `feat(builder): Add CSS source map support`, `fix(server): Correct middleware ordering`
2 changes: 1 addition & 1 deletion commitlint.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ const PUBLIC_PACKAGES = [

const INTERNAL_PACKAGES = [
"documentation",
"shrinkwrap-extractor"
"lockfile-extractor"
];

const ALLOWED_TYPE_SCOPE_COMBINATIONS = {
Expand Down
6 changes: 3 additions & 3 deletions docs/Release-Workflow.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,9 +37,9 @@ The workflow consists of three main jobs:

### 3. `publish-cli` Job
- **Trigger**: All other packages have been published
- **Purpose**: Generates `npm-shrinkwrap.json` using `shrinkwrap-extractor` and publishes the CLI package
- **Why separate**: The shrinkwrap must contain published registry versions of workspace packages, not workspace links. This requires all dependencies to be available on npm registry first.
- **How it works**: The `shrinkwrap-extractor` reads the monorepo's `package-lock.json`, extracts production dependencies for `@ui5/cli`, converts workspace references to registry URLs, and generates a valid `npm-shrinkwrap.json` that will be included in the published CLI package.
- **Purpose**: Generates a standalone `package-lock.json` using `lockfile-extractor`, installs outside the workspace, packs with `bundleDependencies`, and publishes the CLI package
- **Why separate**: The lock file must reference published registry versions of workspace packages, not workspace symlinks. This requires all `@ui5/*` dependencies to be available on the npm registry first.
- **How it works**: The `lockfile-extractor` reads the monorepo's `package-lock.json`, extracts production dependencies for `@ui5/cli`, resolves workspace references to registry URLs, and generates a standalone `package-lock.json`. The package is then copied outside the workspace, `npm ci` installs exact versions, and `npm pack` bundles all `node_modules` into the tarball via `bundleDependencies: true`.

## Release Please Configuration

Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
version = 1
SPDX-PackageName = "ui5-shrinkwrap-extractor"
SPDX-PackageName = "ui5-lockfile-extractor"
SPDX-PackageSupplier = "SAP OpenUI5 <openui5@sap.com>"
SPDX-PackageDownloadLocation = "https://github.com/UI5/cli/tree/main/packages/shrinkwrap-extractor"
SPDX-PackageDownloadLocation = "https://github.com/UI5/cli/tree/main/internal/lockfile-extractor"
SPDX-PackageComment = "The code in this project may include calls to APIs (“API Calls”) of\n SAP or third-party products or services developed outside of this project\n (“External Products”).\n “APIs” means application programming interfaces, as well as their respective\n specifications and implementing code that allows software to communicate with\n other software.\n API Calls to External Products are not licensed under the open source license\n that governs this project. The use of such API Calls and related External\n Products are subject to applicable additional agreements with the relevant\n provider of the External Products. In no event shall the open source license\n that governs this project grant any rights in or to any External Products,or\n alter, expand or supersede any terms of the applicable additional agreements.\n If you have a valid license agreement with SAP for the use of a particular SAP\n External Product, then you may make use of any API Calls included in this\n project’s code for that SAP External Product, subject to the terms of such\n license agreement. If you do not have a valid license agreement for the use of\n a particular SAP External Product, then you may only make use of any API Calls\n in this project for that SAP External Product for your internal, non-productive\n and non-commercial test and evaluation of such API Calls. Nothing herein grants\n you any rights to use or access any SAP External Product, or provide any third\n parties the right to use of access any SAP External Product, through API Calls."

[[annotations]]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,22 +2,22 @@

import {readFile, writeFile} from "node:fs/promises";
import {join} from "node:path";
import convertPackageLockToShrinkwrap from "./lib/convertPackageLockToShrinkwrap.js";
import extractFromWorkspaceLockfile from "./lib/extractFromWorkspaceLockfile.js";

async function main() {
const args = process.argv.slice(2);

// Validate arguments
if (args.length !== 1) {
console.error("Error: Expected exactly 1 argument");
console.error("Usage: shrinkwrap-extractor <path-to-workspace-root>");
console.error("Usage: lockfile-extractor <path-to-workspace-root>");
process.exit(1);
}

const [workspaceRootPath] = args;

try {
console.log(`Generating shrinkwrap in: ${process.cwd()}`);
console.log(`Generating lockfile in: ${process.cwd()}`);
console.log(`Using workspace root: ${workspaceRootPath}`);

// Read and parse package.json
Expand All @@ -33,17 +33,17 @@ async function main() {

console.log(`Converting dependencies for package: ${packageName}`);

// Extract into shrinkwrap
const shrinkwrap = await convertPackageLockToShrinkwrap(workspaceRootPath, packageName);
// Extract into lockfile
const lockfile = await extractFromWorkspaceLockfile(workspaceRootPath, packageName);

// Write npm-shrinkwrap.json to current working directory
const outputPath = join(process.cwd(), "npm-shrinkwrap.json");
const shrinkwrapContent = JSON.stringify(shrinkwrap, null, "\t");
// Write package-lock.json to current working directory
const outputPath = join(process.cwd(), "package-lock.json");
const lockfileContent = JSON.stringify(lockfile, null, "\t");

await writeFile(outputPath, shrinkwrapContent, "utf-8");
await writeFile(outputPath, lockfileContent, "utf-8");

console.log(`Successfully generated npm-shrinkwrap.json with ` +
`${Object.keys(shrinkwrap.packages).length - 1} dependencies (excluding root)`);
console.log(`Successfully generated package-lock.json with ` +
`${Object.keys(lockfile.packages).length - 1} dependencies (excluding root)`);
console.log(`Output written to: ${outputPath}`);
} catch (error) {
console.error(`Unexpected error: ${error.message}`);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ async function readJson(filePath) {
return JSON.parse(jsonString);
}

export default async function convertPackageLockToShrinkwrap(workspaceRootDir, targetPackageName) {
export default async function extractFromWorkspaceLockfile(workspaceRootDir, targetPackageName) {
const packageLockJson = await readJson(path.join(workspaceRootDir, "package-lock.json"));

// Input validation
Expand Down Expand Up @@ -60,6 +60,7 @@ export default async function convertPackageLockToShrinkwrap(workspaceRootDir, t

// Using the keys, extract relevant package-entries from package-lock.json
const extractedPackages = Object.create(null);
const extractedPackageNodes = new Map();
for (let [packageLoc, node] of relevantPackageLocations) {
let pkg = packageLockJson.packages[packageLoc];
if (pkg.link) {
Expand All @@ -82,7 +83,24 @@ export default async function convertPackageLockToShrinkwrap(workspaceRootDir, t
pkg.resolved = resolved;
pkg.integrity = integrity;
}
const existingNode = extractedPackageNodes.get(packageLoc);
if (existingNode &&
(existingNode.packageName !== node.packageName || existingNode.version !== node.version)) {
const existingIsFromTarget = isDirectDependencyOf(existingNode, targetPackageName);
const currentIsFromTarget = isDirectDependencyOf(node, targetPackageName);
if (existingIsFromTarget !== currentIsFromTarget) {
if (currentIsFromTarget) {
nestPackageBelowDependents(existingNode, extractedPackages[packageLoc], extractedPackages,
relevantPackageLocations, targetPackageName, tree.packageName);
} else {
nestPackageBelowDependents(node, pkg, extractedPackages, relevantPackageLocations,
targetPackageName, tree.packageName);
continue;
}
}
}
extractedPackages[packageLoc] = pkg;
extractedPackageNodes.set(packageLoc, node);
}

// Sort packages by key to ensure consistent order (just like the npm cli does it)
Expand All @@ -92,16 +110,16 @@ export default async function convertPackageLockToShrinkwrap(workspaceRootDir, t
sortedExtractedPackages[key] = extractedPackages[key];
}

// Generate npm-shrinkwrap.json
const shrinkwrap = {
// Generate package-lock.json
const lockfile = {
name: targetPackageName,
version: cliNode.version,
lockfileVersion: 3,
requires: true,
packages: sortedExtractedPackages
};

return shrinkwrap;
return lockfile;
}

/**
Expand All @@ -112,9 +130,9 @@ export default async function convertPackageLockToShrinkwrap(workspaceRootDir, t
*
* @param {string} location - Package location from arborist
* @param {object} node - Package node from arborist
* @param {string} targetPackageName - Target package name for shrinkwrap file
* @param {string} targetPackageName - Target package name for lockfile file
* @param {string} rootPackageName - Root / workspace package name
* @returns {string} - Normalized location for npm-shrinkwrap.json
* @returns {string} - Normalized location for package-lock.json
*/
function normalizePackageLocation(location, node, targetPackageName, rootPackageName) {
const topPackageName = node.top.packageName;
Expand All @@ -129,6 +147,22 @@ function normalizePackageLocation(location, node, targetPackageName, rootPackage
return location;
}

function nestPackageBelowDependents(node, pkg, extractedPackages, relevantPackageLocations,
targetPackageName, rootPackageName) {
for (const edge of node.edgesIn) {
if (edge.dev || !relevantPackageLocations.has(edge.from.location)) {
continue;
}
const parentLoc = normalizePackageLocation(edge.from.location, edge.from,
targetPackageName, rootPackageName);
extractedPackages[`${parentLoc}/node_modules/${edge.name}`] = pkg;
}
}

function isDirectDependencyOf(node, packageName) {
return Array.from(node.edgesIn).some((edge) => !edge.dev && edge.from.packageName === packageName);
}

function collectDependencies(node, relevantPackageLocations) {
if (relevantPackageLocations.has(node.location)) {
// Already processed
Expand Down
Loading
Loading