diff --git a/.github/workflows/cd-npm.yml b/.github/workflows/cd-npm.yml new file mode 100644 index 00000000..ffc16aa7 --- /dev/null +++ b/.github/workflows/cd-npm.yml @@ -0,0 +1,229 @@ +name: Publish @uipath/coreipc to GitHub Packages and npmjs + +on: + repository_dispatch: + types: [publish-npm] + +permissions: + contents: read + +concurrency: + group: publish-npm-coreipc + cancel-in-progress: false + +env: + JS_DIR: src/Clients/js + CSPROJ: src/UiPath.CoreIpc/UiPath.CoreIpc.csproj + NODE_VERSION: '20.11.0' + +jobs: + pack: + name: Pack tarballs + runs-on: ubuntu-latest + outputs: + version: ${{ steps.resolve.outputs.version }} + stable: ${{ steps.resolve.outputs.stable }} + steps: + # client_payload is attacker-controlled if the dispatch token leaks: read it + # through env, never inline it into a run: script. + - name: Resolve & validate dispatch payload + id: ref + env: + DISPATCH_SHA: ${{ github.event.client_payload.sha }} + DISPATCH_VERSION: ${{ github.event.client_payload.version }} + run: | + set -euo pipefail + REF="$DISPATCH_SHA" + if [ -z "$REF" ]; then echo "::error::No client_payload.sha provided."; exit 1; fi + if ! printf '%s' "$REF" | grep -Eq '^[0-9a-fA-F]{40}$'; then + echo "::error::client_payload.sha '$REF' is not a 40-char commit hash — refusing."; exit 1 + fi + VER="$DISPATCH_VERSION" + if [ -z "$VER" ]; then echo "::error::No client_payload.version provided."; exit 1; fi + if ! printf '%s' "$VER" | grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?$'; then + echo "::error::client_payload.version '$VER' is not a plain semver version — refusing."; exit 1 + fi + echo "ref=$REF" >> "$GITHUB_OUTPUT" + echo "version=$VER" >> "$GITHUB_OUTPUT" + + - name: Checkout exact commit + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 + with: + ref: ${{ steps.ref.outputs.ref }} + fetch-depth: 0 # the ancestry check below needs the full graph + + # A build-number suffix is the ADO CI convention (2.5.3-20260724-02); anything + # else means the payload and the commit disagree. + - name: Resolve & validate version against the commit + id: resolve + env: + DISPATCHED: ${{ steps.ref.outputs.version }} + run: | + set -euo pipefail + BASE=$(grep -oPm1 '(?<=)[^<]+' "$CSPROJ" | tr -d '[:space:]') + if [ -z "$BASE" ]; then + echo "::error::No found in $CSPROJ."; exit 1 + fi + if [ "$DISPATCHED" = "$BASE" ]; then + STABLE=true + elif [ "${DISPATCHED#"$BASE"-}" != "$DISPATCHED" ]; then + STABLE=false + else + echo "::error::Dispatched version '$DISPATCHED' does not match the csproj '$BASE' at this commit." + exit 1 + fi + echo "version=$DISPATCHED" >> "$GITHUB_OUTPUT" + echo "stable=$STABLE" >> "$GITHUB_OUTPUT" + echo "Publishing $DISPATCHED (stable=$STABLE)" + + # The repo is public, so fork PR heads are fetchable by SHA from refs/pull/*. + # Ancestry of master is what keeps one out. + - name: Refuse commits that are not on master + env: + SHA: ${{ steps.ref.outputs.ref }} + run: | + set -euo pipefail + git fetch --no-tags origin master + if ! git merge-base --is-ancestor "$SHA" FETCH_HEAD; then + echo "::error::Commit $SHA is not an ancestor of origin/master — only master-merged code may be published." + exit 1 + fi + echo "$SHA is on master — OK" + + - name: Setup Node + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 + with: + node-version: ${{ env.NODE_VERSION }} + cache: npm + cache-dependency-path: ${{ env.JS_DIR }}/package-lock.json + + - name: Install dependencies + working-directory: ${{ env.JS_DIR }} + run: npm ci + + # Must precede the build: webpack copies this version into both generated + # packages. + - name: Stamp version into package.json + working-directory: ${{ env.JS_DIR }} + run: npm version "${{ steps.resolve.outputs.version }}" --allow-same-version --no-git-tag-version + + - name: Build + working-directory: ${{ env.JS_DIR }} + run: npm run build + + # Deliberately not reusing the webpack shell plugin's pack into dist-packages/. + - name: Pack + working-directory: ${{ env.JS_DIR }} + run: | + set -euo pipefail + rm -rf "$RUNNER_TEMP/tarballs" + npm pack ./dist/prepack/node --pack-destination "$RUNNER_TEMP/tarballs" + npm pack ./dist/prepack/web --pack-destination "$RUNNER_TEMP/tarballs" + ls -l "$RUNNER_TEMP/tarballs" + + - name: Verify tarballs + env: + VERSION: ${{ steps.resolve.outputs.version }} + run: | + set -euo pipefail + cd "$RUNNER_TEMP/tarballs" + for expected in "uipath-coreipc-$VERSION.tgz" "uipath-coreipc-web-$VERSION.tgz"; do + if [ ! -f "$expected" ]; then + echo "::error::Expected tarball '$expected' was not produced. Got: $(ls)"; exit 1 + fi + done + if [ "$(ls -1 ./*.tgz | wc -l)" -ne 2 ]; then + echo "::error::Expected exactly 2 tarballs, got: $(ls)"; exit 1 + fi + + - name: Upload tarballs + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: npm-tarballs + path: ${{ runner.temp }}/tarballs/*.tgz + + publish-github-packages: + name: Publish to GitHub Packages + needs: pack + runs-on: ubuntu-latest + environment: github-packages + permissions: + contents: read + packages: write + steps: + - name: Retrieve tarballs + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 + with: + name: npm-tarballs + path: tarballs/ + + - name: Setup Node for GitHub Packages + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 + with: + node-version: ${{ env.NODE_VERSION }} + registry-url: https://npm.pkg.github.com + scope: '@uipath' + + # Skip-if-present keeps re-runs safe, matching skip-existing on the PyPI side. + - name: Publish both packages + env: + NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + VERSION: ${{ needs.pack.outputs.version }} + run: | + set -euo pipefail + publish_if_new() { + if npm view "$1@$VERSION" version >/dev/null 2>&1; then + echo "$1@$VERSION is already on GitHub Packages — skipping." + else + npm publish "$2" + fi + } + publish_if_new '@uipath/coreipc' "tarballs/uipath-coreipc-$VERSION.tgz" + publish_if_new '@uipath/coreipc-web' "tarballs/uipath-coreipc-web-$VERSION.tgz" + + publish-npmjs: + name: Publish to npmjs (Trusted Publishing) + needs: [pack, publish-github-packages] + if: needs.pack.outputs.stable == 'true' + runs-on: ubuntu-latest + environment: npm + permissions: + contents: read + id-token: write + steps: + - name: Retrieve tarballs + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 + with: + name: npm-tarballs + path: tarballs/ + + - name: Setup Node for npmjs + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 + with: + node-version: ${{ env.NODE_VERSION }} + registry-url: https://registry.npmjs.org + scope: '@uipath' + + # Trusted Publishing needs npm >= 11.5.1; Node 20.11.0 ships npm 10.x. + - name: Upgrade npm for Trusted Publishing + run: | + set -euo pipefail + npm install -g npm@^11.5.1 + npm --version + + # No NODE_AUTH_TOKEN by design — npm trades the job's OIDC token for a + # short-lived credential. @uipath/coreipc-web is absent because a Trusted + # Publisher can only be configured on a name that already exists on npmjs. + # Queried over plain HTTPS, not `npm view`: setup-node writes an _authToken + # placeholder for a token this job deliberately does not have. + - name: Publish @uipath/coreipc + env: + VERSION: ${{ needs.pack.outputs.version }} + run: | + set -euo pipefail + code=$(curl -sS -o /dev/null -w '%{http_code}' "https://registry.npmjs.org/@uipath%2Fcoreipc/$VERSION") + if [ "$code" = "200" ]; then + echo "@uipath/coreipc@$VERSION is already on npmjs — skipping." + exit 0 + fi + npm publish "tarballs/uipath-coreipc-$VERSION.tgz" --access public diff --git a/src/CI/azp-js.publish-npm.steps.yaml b/src/CI/azp-js.publish-npm.steps.yaml index 468965db..ac37df13 100644 --- a/src/CI/azp-js.publish-npm.steps.yaml +++ b/src/CI/azp-js.publish-npm.steps.yaml @@ -31,11 +31,6 @@ steps: artifactName: 'NPM package' targetPath: '$(Pipeline.Workspace)/NPM package' -- task: NodeTool@0 - displayName: 'Use Node.js 20.11.0' - inputs: - versionSpec: '20.11.0' - - task: ExtractFiles@1 displayName: 'Extract Files' inputs: @@ -43,39 +38,68 @@ steps: destinationFolder: '$(System.DefaultWorkingDirectory)/unzipped' cleanDestinationFolder: true -# --------------------------------------------------------------------- -# Secondary target: GitHub Packages (best-effort, currently expected to fail) -# --------------------------------------------------------------------- -# Following the May 11–12, 2026 npm supply-chain incident (Mini Shai-Hulud -# / TanStack), UiPath revoked classic GitHub PATs org-wide and is migrating -# everyone to fine-grained PATs. Fine-grained PATs don't have the Packages -# permission available at org level for UiPath — so the existing -# `PublishNPM` service connection can no longer authenticate. +# --------------------------------------------------------------------------- +# Publishing itself lives in GitHub Actions (.github/workflows/cd-npm.yml). +# --------------------------------------------------------------------------- +# ADO used to `npm publish` the two generated package directories straight to +# GitHub Packages through the `PublishNPM` service connection. That connection is +# a classic GitHub PAT, and UiPath revoked classic PATs org-wide after the +# May 11-12, 2026 npm supply-chain incident (Mini Shai-Hulud / TanStack); the +# fine-grained replacements don't expose the Packages permission at org level, so +# there is no ADO-side auth story left. # -# Per Liviu Bud's #dev announcement on 2026-05-25, a sanctioned pipeline- -# auth replacement is being worked on but not yet available: -# https://uipath.enterprise.slack.com/archives/CMDRA3VFH/p1779699547818419 +# GitHub Actions has one for free: a workflow in UiPath/coreipc gets a GITHUB_TOKEN +# with `packages: write`, and both `coreipc` and `coreipc-web` are already linked to +# this repo. The same workflow additionally publishes STABLE releases to public +# npmjs via Trusted Publishing (OIDC), which ADO cannot do at all — npm only trusts +# GitHub Actions identities. # -# We leave the GitHub Packages publish wired up with continueOnError so -# (a) the run doesn't fail when the publish fails on policy, and -# (b) the publish resumes automatically the moment the service connection -# is updated with whatever the platform team ships. -# -# Each Publish_NPM run will be marked "Succeeded with issues" until then. -# Revert continueOnError when the publish path is healthy again. -# --------------------------------------------------------------------- -- task: Npm@1 - displayName: 'Publish to GitHub Packages — NodeJS (best-effort)' - continueOnError: true - inputs: - command: 'publish' - workingDir: '$(System.DefaultWorkingDirectory)/unzipped/dist/prepack/node' - publishEndpoint: PublishNPM +# So this stage no longer publishes. It resolves what the CI build actually produced +# and whispers to GitHub to publish exactly that commit at exactly that version. +# The version is read out of the packed package.json rather than recomputed, so +# there is one source of truth for it. +# --------------------------------------------------------------------------- +- script: | + set -euo pipefail -- task: Npm@1 - displayName: 'Publish to GitHub Packages — Web (best-effort)' - continueOnError: true - inputs: - command: 'publish' - workingDir: '$(System.DefaultWorkingDirectory)/unzipped/dist/prepack/web' - publishEndpoint: PublishNPM + # 1. The version the CI build stamped into the generated NodeJS package. + PKG="$(System.DefaultWorkingDirectory)/unzipped/dist/prepack/node/package.json" + if [ ! -f "$PKG" ]; then + echo "##vso[task.logissue type=error]No packed package.json at $PKG — did the CI build produce the 'NPM package' artifact?" + exit 1 + fi + VERSION=$(jq -r '.version' "$PKG") + if [ -z "$VERSION" ] || [ "$VERSION" = "null" ]; then + echo "##vso[task.logissue type=error]Could not read .version from $PKG." + exit 1 + fi + + # 2. Resolve the published build's commit (ADO REST). + build=$(curl -sSf -H "Authorization: Bearer $SYSTEM_ACCESSTOKEN" \ + "${SYSTEM_COLLECTIONURI}${SYSTEM_TEAMPROJECT}/_apis/build/builds/${BUILD_ID}?api-version=7.1") + SHA=$(printf '%s' "$build" | jq -r '.sourceVersion') + BRANCH=$(printf '%s' "$build" | jq -r '.sourceBranch') + echo "buildId ${BUILD_ID}: branch=${BRANCH} commit=${SHA} version=${VERSION}" + + if ! printf '%s' "$SHA" | grep -Eq '^[0-9a-fA-F]{40}$'; then + echo "##vso[task.logissue type=error]No valid commit SHA for buildId ${BUILD_ID} (got '${SHA}')." + exit 1 + fi + + # 3. Whisper. The workflow re-validates everything it is told (the SHA is a real + # commit, the version matches the csproj at that commit, stable releases are + # on master) — this side is convenience, not a trust boundary. + echo "Dispatching publish-npm for ${VERSION} (${SHA})" + curl -sSf -X POST \ + -H "Accept: application/vnd.github+json" \ + -H "Authorization: Bearer $GITHUB_DISPATCH_TOKEN" \ + -H "X-GitHub-Api-Version: 2022-11-28" \ + https://api.github.com/repos/UiPath/coreipc/dispatches \ + -d "{\"event_type\":\"publish-npm\",\"client_payload\":{\"sha\":\"${SHA}\",\"version\":\"${VERSION}\"}}" + displayName: 'Dispatch publish-npm → GitHub Actions' + env: + SYSTEM_ACCESSTOKEN: $(System.AccessToken) + SYSTEM_COLLECTIONURI: $(System.CollectionUri) + SYSTEM_TEAMPROJECT: $(System.TeamProject) + BUILD_ID: ${{ parameters.reuseArtifactsFromBuildId }} + GITHUB_DISPATCH_TOKEN: $(GITHUB_DISPATCH_TOKEN) diff --git a/src/CI/azp-publish.yaml b/src/CI/azp-publish.yaml index 88c75f31..222dec23 100644 --- a/src/CI/azp-publish.yaml +++ b/src/CI/azp-publish.yaml @@ -32,7 +32,7 @@ parameters: default: true - name: publishNpm - displayName: 'Publish NPM (Node + Web) → uipath-ipc-deps (+ GitHub Packages best-effort)' + displayName: 'Publish NPM (Node + Web) → GitHub Packages (+ npmjs on a stable release)' type: boolean default: true @@ -120,6 +120,8 @@ stages: - stage: Publish_NPM displayName: '🚚 Publish NPM' dependsOn: [] + variables: + - group: github-dispatch # supplies secret GITHUB_DISPATCH_TOKEN jobs: - deployment: Publish_NPM_Packages displayName: '📦 Publish NPM (Node + Web)'