This is an automated email from the ASF dual-hosted git repository.

jason810496 pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/airflow.git


The following commit(s) were added to refs/heads/main by this push:
     new cd464f25c80 Publish TypeScript SDK with trusted credentials (#71843)
cd464f25c80 is described below

commit cd464f25c807f635a2504d8ae3ae24f2b18d63bd
Author: Jason(Zhe-You) Liu <[email protected]>
AuthorDate: Wed Aug 26 16:06:29 2026 +0800

    Publish TypeScript SDK with trusted credentials (#71843)
    
    * Publish TypeScript SDK with trusted credentials
    
    * Support staged TypeScript SDK releases
    
    * Harden TypeScript SDK release publishing
    
    * Lint, test, and document TypeScript SDK releases
    
    Scripts under ts-sdk/scripts/ fell outside the package's lint, format,
    and test globs, letting them drift from the rest of the SDK's coding
    standards, and running the release input validator's tests through a
    second `node --test` invocation split their results away from `pnpm
    test`. The release workflow also required a tag to already exist on
    apache/airflow before it could be dispatched against that ref, but the
    publishing guide never spelled out the commands for that step.
    
    * Fix TypeScript SDK prerelease dist-tag validation
    
    The prerelease-channel regex included the identifier separator hyphen,
    so a tag like ts-sdk/1.0.0-rc-1 derived a channel of "rc-" instead of
    "rc" and was rejected for a valid npm_tag=rc dispatch. A numeric-only
    prerelease identifier also produced a malformed error message that
    named the "next" dist-tag twice. Reading the package name from
    package.json instead of a second hardcoded literal keeps the two from
    drifting apart. The README now notes that a staged release's dist-tag
    only moves at npm's later, unvalidated approval step, so staged
    versions must be approved in the order they were requested.
    
    * Report the npm version that fails the release preflight
    
    The preflight exited bare on an npm older than 11.15, so a release manager 
saw
    only "Process completed with exit code 1" on the one step whose failure is
    least self explanatory. It now names the detected version, the required
    version, and where to change the pin.
    
    * Drop the unreachable empty-artifact-directory assertion
    
    The emptiness check ran immediately after mkdir -p on a path under 
RUNNER_TEMP
    on a fresh runner, so it could never fail. The following assertion that 
exactly
    one tarball exists is the one that actually constrains npm pack.
    
    * Keep the release tarball for the approval window
    
    Retention runs from upload rather than from run completion, and this 
workflow
    waits on a human environment approval before the tarball is consumed. One 
day
    lost it whenever approval landed more than a day after dispatch, forcing a 
full
    re-dispatch. Seven days matches the rest of the repository.
    
    * Query the npm dist-tag with the validated package name
    
    The dist-tag preflight named the package with a second hardcoded literal, 
so a
    rename of the published package would have left it querying the old name and
    silently comparing against an unrelated dist-tag. It now reuses the name the
    validator already reads from package.json. Verifying package identity moves
    ahead of the query so the one remaining literal still pins the name before
    anything is derived from it.
    
    * Configure one npm trusted publisher for both release paths
    
    The documented setup asked for two trusted-publisher relationships on one
    package to make staging and formal publishing mutually exclusive. npm's
    registry accepts only one configuration per package, so the second command
    fails and the surviving configuration pins a single environment claim that
    rejects the other job's publish - a failure that only surfaces after a
    maintainer has already approved the environment gate. Both paths now share 
one
    configuration and one environment, and the documentation credits the 
separation
    to the GitHub approval gate that actually provides it.
    
    * Stop naming the next dist-tag twice for next-channel prereleases
    
    A prerelease whose channel is already next, such as ts-sdk/1.0.0-next1,
    derived the same dist-tag from both sides of the allowed-tag message and 
told
    the release manager to "use the next or next npm dist-tag". The numeric-only
    identifier case was handled earlier; this one shares its shape.
    
    * Verify package contents before packing the release tarball
    
    The packaging check that rejects source, test, and configuration files from 
the
    published tarball only ran when a contributor remembered to invoke it by 
hand,
    so nothing enforced it on the one run where it matters. Running it in the
    release build job, before the tarball is created, makes it a gate rather 
than a
    convention while keeping it out of pull request CI.
    
    Depends on #71399, which adds the verify:package script.
---
 .github/workflows/ts-sdk-release.yml               | 208 +++++++++++++++++++++
 .../language-sdks/typescript.rst                   |   8 +-
 ts-sdk/README.md                                   |  87 +++++++++
 ts-sdk/package.json                                |   8 +-
 ts-sdk/scripts/generate-supervisor.mjs             |   1 +
 ts-sdk/scripts/validate-release-inputs.mjs         | 158 ++++++++++++++++
 ts-sdk/scripts/validate-release-inputs.test.mjs    | 134 +++++++++++++
 7 files changed, 596 insertions(+), 8 deletions(-)

diff --git a/.github/workflows/ts-sdk-release.yml 
b/.github/workflows/ts-sdk-release.yml
new file mode 100644
index 00000000000..57debc133d9
--- /dev/null
+++ b/.github/workflows/ts-sdk-release.yml
@@ -0,0 +1,208 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+---
+name: Release TypeScript SDK
+
+on:  # yamllint disable-line rule:truthy
+  workflow_dispatch:
+    inputs:
+      release_type:
+        description: "Stage the package for review or publish it directly"
+        required: true
+        type: choice
+        options:
+          - staged
+          - formal
+      tag:
+        description: "TypeScript SDK release tag (ts-sdk/<version>)"
+        required: true
+        type: string
+      npm_tag:
+        description: "npm dist-tag (for example: beta or latest)"
+        required: true
+        type: string
+
+permissions:
+  contents: read
+
+concurrency:
+  group: ts-sdk-npm-release
+  cancel-in-progress: false
+
+jobs:
+  build:
+    name: Verify and package release
+    runs-on: ubuntu-latest
+    outputs:
+      artifact_name: ${{ steps.package.outputs.artifact_name }}
+      package_file: ${{ steps.release.outputs.package_file }}
+      sha256: ${{ steps.package.outputs.sha256 }}
+      version: ${{ steps.release.outputs.version }}
+    steps:
+      - name: Checkout dispatched release tag
+        uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1  # 
v7.0.1
+        with:
+          fetch-depth: 0
+          persist-credentials: false
+      - name: Set up Node.js
+        uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020  # 
v7.0.0
+        with:
+          node-version: "24.19.0"
+          registry-url: "https://registry.npmjs.org";
+          package-manager-cache: false
+      - name: Enable the pinned pnpm version
+        run: corepack enable
+      - name: Validate release inputs
+        id: release
+        working-directory: ts-sdk
+        env:
+          NPM_TAG: ${{ inputs.npm_tag }}
+          RELEASE_TAG: ${{ inputs.tag }}
+        run: node scripts/validate-release-inputs.mjs
+      - name: Confirm dispatch and checkout use the release tag
+        env:
+          DISPATCH_REF: ${{ github.ref }}
+          DISPATCH_SHA: ${{ github.sha }}
+          RELEASE_TAG: ${{ inputs.tag }}
+        run: |
+          set -euo pipefail
+          test "${DISPATCH_REF}" = "refs/tags/${RELEASE_TAG}"
+          test "${DISPATCH_SHA}" = "$(git rev-parse HEAD)"
+          test "$(git rev-parse "refs/tags/${RELEASE_TAG}^{commit}")" = "$(git 
rev-parse HEAD)"
+          git merge-base --is-ancestor HEAD "$(git rev-parse 
refs/remotes/origin/main)"
+      - name: Verify package identity
+        working-directory: ts-sdk
+        env:
+          EXPECTED_VERSION: ${{ steps.release.outputs.version }}
+        run: |
+          set -euo pipefail
+          test "$(node --print "require('./package.json').name")" = 
"apache-airflow-ts-sdk"
+          test "$(node --print "require('./package.json').version")" = 
"${EXPECTED_VERSION}"
+      - name: Confirm release advances the npm dist-tag
+        working-directory: ts-sdk
+        env:
+          NPM_TAG: ${{ inputs.npm_tag }}
+          PACKAGE_NAME: ${{ steps.release.outputs.package_name }}
+          RELEASE_TAG: ${{ inputs.tag }}
+        run: |
+          set -euo pipefail
+          error_file="${RUNNER_TEMP}/npm-view-error.log"
+          set +e
+          current_version="$(npm view "${PACKAGE_NAME}" "dist-tags.${NPM_TAG}" 
2>"${error_file}")"
+          view_status=$?
+          set -e
+          if [[ ${view_status} -ne 0 ]]; then
+            if grep -q "E404" "${error_file}"; then
+              current_version=""
+            else
+              cat "${error_file}" >&2
+              exit "${view_status}"
+            fi
+          fi
+          CURRENT_DIST_TAG_VERSION="${current_version}" node 
scripts/validate-release-inputs.mjs
+      - name: Install and test package
+        working-directory: ts-sdk
+        run: |
+          set -euo pipefail
+          pnpm install --frozen-lockfile
+          pnpm run lint
+          pnpm run format:check
+          pnpm run typecheck
+          pnpm test
+      - name: Verify package contents
+        working-directory: ts-sdk
+        run: pnpm run verify:package
+      - name: Create package tarball
+        id: package
+        working-directory: ts-sdk
+        env:
+          PACKAGE_FILE: ${{ steps.release.outputs.package_file }}
+          VERSION: ${{ steps.release.outputs.version }}
+        run: |
+          set -euo pipefail
+          artifact_dir="${RUNNER_TEMP}/ts-sdk-package"
+          mkdir -p "${artifact_dir}"
+          npm pack --pack-destination "${artifact_dir}"
+          artifact_path="${artifact_dir}/${PACKAGE_FILE}"
+          test -f "${artifact_path}"
+          test "$(find "${artifact_dir}" -maxdepth 1 -type f -name '*.tgz' | 
wc -l)" -eq 1
+          echo "artifact_name=ts-sdk-package-${VERSION}" >> "${GITHUB_OUTPUT}"
+          echo "sha256=$(sha256sum "${artifact_path}" | awk '{print $1}')" >> 
"${GITHUB_OUTPUT}"
+      - name: Upload package tarball
+        uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a 
 # v7.0.1
+        with:
+          name: ${{ steps.package.outputs.artifact_name }}
+          path: ${{ runner.temp }}/ts-sdk-package/${{ 
steps.release.outputs.package_file }}
+          if-no-files-found: error
+          retention-days: 7
+
+  publish:
+    name: ${{ inputs.release_type == 'staged' && 'Stage release on npm' || 
'Publish formal release to npm' }}
+    needs: build
+    runs-on: ubuntu-latest
+    environment:
+      name: ts-sdk-npm-release
+    permissions:
+      contents: read
+      id-token: write
+    steps:
+      - name: Set up Node.js
+        uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020  # 
v7.0.0
+        with:
+          node-version: "24.19.0"
+          registry-url: "https://registry.npmjs.org";
+          package-manager-cache: false
+      - name: Check npm supports staged and trusted publishing
+        run: |
+          node --input-type=module --eval '
+            import { execFileSync } from "node:child_process";
+            const npmVersion = execFileSync("npm", ["--version"], { encoding: 
"utf8" }).trim();
+            const [major, minor] = npmVersion.split(".").map(Number);
+            if (major < 11 || (major === 11 && minor < 15)) {
+              console.error(
+                `npm ${npmVersion} does not support staged and trusted 
publishing; ` +
+                  "npm 11.15.0 or later is required. Raise the node-version 
pin in this workflow.",
+              );
+              process.exit(1);
+            }
+          '
+      - name: Download verified package tarball
+        uses: 
actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c  # v8.0.1
+        with:
+          name: ${{ needs.build.outputs.artifact_name }}
+          path: ${{ runner.temp }}/ts-sdk-package
+      - name: Verify package tarball checksum
+        env:
+          EXPECTED_SHA256: ${{ needs.build.outputs.sha256 }}
+          PACKAGE_FILE: ${{ needs.build.outputs.package_file }}
+        run: |
+          set -euo pipefail
+          artifact_path="${RUNNER_TEMP}/ts-sdk-package/${PACKAGE_FILE}"
+          test -f "${artifact_path}"
+          echo "${EXPECTED_SHA256}  ${artifact_path}" | sha256sum --check 
--strict -
+      - name: Stage with npm trusted publishing
+        if: inputs.release_type == 'staged'
+        env:
+          NPM_TAG: ${{ inputs.npm_tag }}
+          PACKAGE_FILE: ${{ needs.build.outputs.package_file }}
+        run: npm stage publish "${RUNNER_TEMP}/ts-sdk-package/${PACKAGE_FILE}" 
--tag "${NPM_TAG}"
+      - name: Publish with npm trusted publishing
+        if: inputs.release_type == 'formal'
+        env:
+          NPM_TAG: ${{ inputs.npm_tag }}
+          PACKAGE_FILE: ${{ needs.build.outputs.package_file }}
+        run: npm publish "${RUNNER_TEMP}/ts-sdk-package/${PACKAGE_FILE}" --tag 
"${NPM_TAG}"
diff --git 
a/airflow-core/docs/authoring-and-scheduling/language-sdks/typescript.rst 
b/airflow-core/docs/authoring-and-scheduling/language-sdks/typescript.rst
index 266aad2d0c4..205cf629635 100644
--- a/airflow-core/docs/authoring-and-scheduling/language-sdks/typescript.rst
+++ b/airflow-core/docs/authoring-and-scheduling/language-sdks/typescript.rst
@@ -27,13 +27,13 @@ plain JavaScript), running on Node.js. A matching Python 
stub Dag still declares
 dependencies; individual tasks delegate to a Node.js subprocess that is 
spawned by
 :class:`~airflow.sdk.coordinators.node.NodeCoordinator` for each task instance.
 
-The SDK is an ESM-only package that ships from the ``ts-sdk/`` directory of 
the Airflow repository. It is currently in **beta** and its API may change.
+The SDK is the ``apache-airflow-ts-sdk`` package (ESM-only). It is currently 
in **beta** and its API may change.
 
 .. warning::
 
-  The SDK is not yet published to npm. To try it today, build it from source 
in the
-  `ts-sdk/ <https://github.com/apache/airflow/tree/main/ts-sdk>`__ directory 
of the Airflow repository and
-  depend on it locally (see ``ts-sdk/example/`` for a working setup).
+  Install an available release from npm. To try an unreleased change, build it 
from source in the
+  `ts-sdk/ <https://github.com/apache/airflow/tree/main/ts-sdk>`__ directory 
of the Airflow repository
+  and depend on it locally (see ``ts-sdk/example/`` for a working setup).
 
 .. seealso::
 
diff --git a/ts-sdk/README.md b/ts-sdk/README.md
index 8508b922b11..fc6781d2113 100644
--- a/ts-sdk/README.md
+++ b/ts-sdk/README.md
@@ -338,3 +338,90 @@ gh workflow run "Publish Docs to S3" --repo apache/airflow 
--ref main \
 Use `destination=staging` first to check the output, then `live`. Confirm that
 `https://airflow.apache.org/docs/ts-sdk/stable/` resolves (allow time for cache
 invalidation) and that `/docs/ts-sdk/` redirects to it.
+
+## Publishing
+
+The manually dispatched `Release TypeScript SDK` workflow first builds, tests,
+and hashes one package tarball without OIDC permissions. Its protected publish
+job then either uses [npm's staged-publishing 
flow](https://docs.npmjs.com/staged-publishing/)
+or publishes the formal release directly.
+
+npm's registry supports only one trusted-publisher configuration per package,
+so both release paths share a single configuration pinned to a single GitHub
+environment:
+
+```bash
+npm trust github apache-airflow-ts-sdk \
+  --repo apache/airflow \
+  --file ts-sdk-release.yml \
+  --environment ts-sdk-npm-release \
+  --allow-stage-publish \
+  --allow-publish
+```
+
+Confirm it with `npm trust list apache-airflow-ts-sdk`. A second `npm trust
+github` for the same package is rejected, so replace an outdated configuration
+with `npm trust revoke` first.
+
+Because the registry cannot express "stage only" and "publish only" as separate
+relationships, what separates the two paths is the GitHub environment gate
+rather than an npm-side permission split. Require reviewers and prevent
+self-review on `ts-sdk-npm-release`, restrict its deployment tags to
+`ts-sdk/*`, and protect those tags from updates and deletion with a repository
+ruleset. The pinned `--environment` claim means a workflow edit that drops the
+gate loses the ability to publish at all.
+
+Create and push a `ts-sdk/<version>` tag whose version exactly matches
+`package.json`, then dispatch the workflow on that same tag so npm provenance
+names the source commit that produced the tarball. The tag must already exist
+on `apache/airflow` before the dispatch below can reference it as `--ref`:
+
+```bash
+git tag ts-sdk/1.0.0-beta1 <commit-sha>
+git push upstream ts-sdk/1.0.0-beta1
+```
+
+To submit the package to npm's private staging area for review, run:
+
+```bash
+gh workflow run ts-sdk-release.yml --repo apache/airflow --ref 
ts-sdk/1.0.0-beta1 \
+  -f release_type=staged \
+  -f tag=ts-sdk/1.0.0-beta1 \
+  -f npm_tag=beta
+```
+
+The publish job calls `npm stage publish` only after the unprivileged build job
+has uploaded a checksummed tarball. The version is not publicly installable
+until a maintainer reviews and approves it with 2FA. The following commands
+require npm 11.15 or later:
+
+```bash
+npm stage list apache-airflow-ts-sdk
+npm stage view <stage-id>
+npm stage download <stage-id>
+npm stage approve <stage-id>
+```
+
+The approval cannot run through the trusted-publisher workflow because npm
+requires interactive proof of presence. Reject an unsuitable staged version
+with `npm stage reject <stage-id>`. Do not run the formal workflow for a 
version
+that is already staged; approve or reject that staged version instead.
+
+To publish directly without npm's staging review, trigger the formal path:
+
+```bash
+gh workflow run ts-sdk-release.yml --repo apache/airflow --ref 
ts-sdk/1.0.0-beta1 \
+  -f release_type=formal \
+  -f tag=ts-sdk/1.0.0-beta1 \
+  -f npm_tag=beta
+```
+
+Use `latest` for stable releases and a non-`latest` tag such as `alpha`,
+`beta`, or `rc` for prereleases. The workflow rejects a dispatch that would
+move the selected npm dist-tag backward as of when it runs. For a staged
+release, the dist-tag only actually moves later, at `npm stage approve` time,
+which is not re-validated — approve staged versions in the order they were
+requested so the dist-tag does not regress. Both publication paths use
+short-lived npm OIDC credentials and automatically publish provenance. After
+verifying the trusted-publisher setup, disable token-based publishing and
+revoke obsolete npm automation tokens.
diff --git a/ts-sdk/package.json b/ts-sdk/package.json
index bf3b7fec5e4..7f13f825e2f 100644
--- a/ts-sdk/package.json
+++ b/ts-sdk/package.json
@@ -40,10 +40,10 @@
   ],
   "scripts": {
     "clean": "node -e \"require('node:fs').rmSync('dist', { recursive: true, 
force: true })\"",
-    "lint": "eslint api-docs src tests",
-    "lint:fix": "eslint api-docs src tests --fix",
-    "format:check": "prettier --check api-docs src tests",
-    "format": "prettier --write api-docs src tests",
+    "lint": "eslint api-docs scripts src tests",
+    "lint:fix": "eslint api-docs scripts src tests --fix",
+    "format:check": "prettier --check api-docs scripts src tests",
+    "format": "prettier --write api-docs scripts src tests",
     "typecheck": "tsc --noEmit",
     "test": "vitest run",
     "test:watch": "vitest",
diff --git a/ts-sdk/scripts/generate-supervisor.mjs 
b/ts-sdk/scripts/generate-supervisor.mjs
index d13f93b5e10..f8471565500 100644
--- a/ts-sdk/scripts/generate-supervisor.mjs
+++ b/ts-sdk/scripts/generate-supervisor.mjs
@@ -29,6 +29,7 @@
 // preserving cross-references between schema definitions.
 
 import { compile } from "json-schema-to-typescript";
+import console from "node:console";
 import { readFileSync, writeFileSync, mkdirSync } from "node:fs";
 import { dirname, join } from "node:path";
 import { fileURLToPath } from "node:url";
diff --git a/ts-sdk/scripts/validate-release-inputs.mjs 
b/ts-sdk/scripts/validate-release-inputs.mjs
new file mode 100644
index 00000000000..ac3e868ccfb
--- /dev/null
+++ b/ts-sdk/scripts/validate-release-inputs.mjs
@@ -0,0 +1,158 @@
+#!/usr/bin/env node
+/*!
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+import console from "node:console";
+import { appendFileSync, readFileSync } from "node:fs";
+import process from "node:process";
+import { URL, pathToFileURL } from "node:url";
+
+const PACKAGE_NAME = JSON.parse(
+  readFileSync(new URL("../package.json", import.meta.url), "utf8"),
+).name;
+const SEMVER_PATTERN =
+  
/^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0|[1-9]\d*|\d*[A-Za-z-][0-9A-Za-z-]*)(?:\.(?:0|[1-9]\d*|\d*[A-Za-z-][0-9A-Za-z-]*))*))?$/;
+const NPM_TAG_PATTERN = /^[a-z][a-z0-9._-]*$/;
+
+function parseVersion(version) {
+  const match = SEMVER_PATTERN.exec(version);
+  if (match === null) {
+    throw new Error(`Release version ${JSON.stringify(version)} is not valid 
SemVer`);
+  }
+  return {
+    version,
+    major: BigInt(match[1]),
+    minor: BigInt(match[2]),
+    patch: BigInt(match[3]),
+    prerelease: match[4]?.split(".") ?? [],
+  };
+}
+
+function compareIdentifiers(left, right) {
+  if (left === right) {
+    return 0;
+  }
+  const leftIsNumeric = /^\d+$/.test(left);
+  const rightIsNumeric = /^\d+$/.test(right);
+  if (leftIsNumeric && rightIsNumeric) {
+    return BigInt(left) < BigInt(right) ? -1 : 1;
+  }
+  if (leftIsNumeric) {
+    return -1;
+  }
+  if (rightIsNumeric) {
+    return 1;
+  }
+  return left < right ? -1 : 1;
+}
+
+function compareVersions(left, right) {
+  for (const field of ["major", "minor", "patch"]) {
+    if (left[field] !== right[field]) {
+      return left[field] < right[field] ? -1 : 1;
+    }
+  }
+  if (left.prerelease.length === 0 || right.prerelease.length === 0) {
+    return right.prerelease.length - left.prerelease.length;
+  }
+  const length = Math.max(left.prerelease.length, right.prerelease.length);
+  for (let index = 0; index < length; index += 1) {
+    const leftIdentifier = left.prerelease[index];
+    const rightIdentifier = right.prerelease[index];
+    if (leftIdentifier === undefined) {
+      return -1;
+    }
+    if (rightIdentifier === undefined) {
+      return 1;
+    }
+    const difference = compareIdentifiers(leftIdentifier, rightIdentifier);
+    if (difference !== 0) {
+      return difference;
+    }
+  }
+  return 0;
+}
+
+function getPrereleaseChannel(prerelease) {
+  return prerelease[0]?.match(/^[A-Za-z]+/)?.[0].toLowerCase();
+}
+
+export function validateReleaseInputs({ releaseTag, npmTag, 
currentDistTagVersion = "" }) {
+  if (!releaseTag.startsWith("ts-sdk/")) {
+    throw new Error("Release tag must have the form ts-sdk/<semver>");
+  }
+  const version = releaseTag.slice("ts-sdk/".length);
+  const parsedVersion = parseVersion(version);
+
+  if (!NPM_TAG_PATTERN.test(npmTag)) {
+    throw new Error(`npm tag ${JSON.stringify(npmTag)} is invalid`);
+  }
+  if (parsedVersion.prerelease.length === 0 && npmTag !== "latest") {
+    throw new Error("Stable releases must use the latest npm dist-tag");
+  }
+  if (parsedVersion.prerelease.length > 0) {
+    const channel = getPrereleaseChannel(parsedVersion.prerelease);
+    if (npmTag !== "next" && npmTag !== channel) {
+      const allowedTags =
+        channel === undefined || channel === "next" ? "next" : `${channel} or 
next`;
+      throw new Error(`Prerelease ${version} must use the ${allowedTags} npm 
dist-tag`);
+    }
+  }
+
+  if (currentDistTagVersion !== "") {
+    const currentVersion = parseVersion(currentDistTagVersion);
+    if (compareVersions(parsedVersion, currentVersion) <= 0) {
+      throw new Error(
+        `Release ${version} must be newer than ${npmTag}'s current version 
${currentDistTagVersion}`,
+      );
+    }
+  }
+
+  return {
+    version,
+    packageName: PACKAGE_NAME,
+    packageFile: `${PACKAGE_NAME}-${version}.tgz`,
+  };
+}
+
+function run() {
+  const result = validateReleaseInputs({
+    releaseTag: process.env.RELEASE_TAG ?? "",
+    npmTag: process.env.NPM_TAG ?? "",
+    currentDistTagVersion: process.env.CURRENT_DIST_TAG_VERSION ?? "",
+  });
+  const outputFile = process.env.GITHUB_OUTPUT;
+  if (outputFile === undefined) {
+    throw new Error("GITHUB_OUTPUT is required");
+  }
+  appendFileSync(
+    outputFile,
+    `version=${result.version}\npackage_name=${result.packageName}\n` +
+      `package_file=${result.packageFile}\n`,
+  );
+}
+
+if (process.argv[1] !== undefined && import.meta.url === 
pathToFileURL(process.argv[1]).href) {
+  try {
+    run();
+  } catch (error) {
+    console.error(error instanceof Error ? error.message : error);
+    process.exitCode = 1;
+  }
+}
diff --git a/ts-sdk/scripts/validate-release-inputs.test.mjs 
b/ts-sdk/scripts/validate-release-inputs.test.mjs
new file mode 100644
index 00000000000..fa7f2ee4033
--- /dev/null
+++ b/ts-sdk/scripts/validate-release-inputs.test.mjs
@@ -0,0 +1,134 @@
+/*!
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+import { spawnSync } from "node:child_process";
+import { mkdtempSync, readFileSync, rmSync } from "node:fs";
+import { tmpdir } from "node:os";
+import { join } from "node:path";
+import process from "node:process";
+import { fileURLToPath } from "node:url";
+
+import { describe, expect, it } from "vitest";
+
+import { validateReleaseInputs } from "./validate-release-inputs.mjs";
+
+describe("validateReleaseInputs", () => {
+  it("accepts stable and prerelease channels", () => {
+    expect(validateReleaseInputs({ releaseTag: "ts-sdk/1.0.0", npmTag: 
"latest" })).toEqual({
+      version: "1.0.0",
+      packageName: "apache-airflow-ts-sdk",
+      packageFile: "apache-airflow-ts-sdk-1.0.0.tgz",
+    });
+    expect(
+      validateReleaseInputs({ releaseTag: "ts-sdk/1.0.0-beta1", npmTag: "beta" 
}).version,
+    ).toBe("1.0.0-beta1");
+    expect(
+      validateReleaseInputs({ releaseTag: "ts-sdk/1.0.0-beta.2", npmTag: 
"next" }).version,
+    ).toBe("1.0.0-beta.2");
+    expect(validateReleaseInputs({ releaseTag: "ts-sdk/1.0.0-rc-1", npmTag: 
"rc" }).version).toBe(
+      "1.0.0-rc-1",
+    );
+  });
+
+  it("rejects invalid versions", () => {
+    for (const releaseTag of ["1.0.0", "ts-sdk/01.0.0", "ts-sdk/1.0.0-01", 
"ts-sdk/1.0"]) {
+      expect(() => validateReleaseInputs({ releaseTag, npmTag: "latest" 
})).toThrow(
+        /Release tag|not valid SemVer/,
+      );
+    }
+  });
+
+  it("rejects mismatched npm tags", () => {
+    expect(() => validateReleaseInputs({ releaseTag: "ts-sdk/1.0.0", npmTag: 
"beta" })).toThrow(
+      /Stable releases must use the latest/,
+    );
+    expect(() =>
+      validateReleaseInputs({ releaseTag: "ts-sdk/1.0.0-beta1", npmTag: 
"latest" }),
+    ).toThrow(/must use the beta or next/);
+    expect(() =>
+      validateReleaseInputs({ releaseTag: "ts-sdk/1.0.0-beta1", npmTag: 
"alpha" }),
+    ).toThrow(/must use the beta or next/);
+  });
+
+  it("rejects a numeric-only prerelease identifier without duplicating the 
next dist-tag", () => {
+    expect(() => validateReleaseInputs({ releaseTag: "ts-sdk/1.0.0-0", npmTag: 
"beta" })).toThrow(
+      "Prerelease 1.0.0-0 must use the next npm dist-tag",
+    );
+  });
+
+  it("rejects a next-channel prerelease without duplicating the next 
dist-tag", () => {
+    expect(() =>
+      validateReleaseInputs({ releaseTag: "ts-sdk/1.0.0-next1", npmTag: "beta" 
}),
+    ).toThrow("Prerelease 1.0.0-next1 must use the next npm dist-tag");
+  });
+
+  it("rejects malformed npm tags", () => {
+    for (const npmTag of ["", "1.0.0", "Beta", "bad tag", "-beta"]) {
+      expect(() => validateReleaseInputs({ releaseTag: "ts-sdk/1.0.0-beta1", 
npmTag })).toThrow(
+        /npm tag|must use/,
+      );
+    }
+  });
+
+  it("requires a release to advance the selected dist-tag", () => {
+    expect(
+      validateReleaseInputs({
+        releaseTag: "ts-sdk/1.0.0-beta.2",
+        npmTag: "beta",
+        currentDistTagVersion: "1.0.0-beta.1",
+      }).version,
+    ).toBe("1.0.0-beta.2");
+    for (const releaseTag of ["ts-sdk/1.0.0-beta.1", "ts-sdk/1.0.0-beta.0"]) {
+      expect(() =>
+        validateReleaseInputs({
+          releaseTag,
+          npmTag: "beta",
+          currentDistTagVersion: "1.0.0-beta.1",
+        }),
+      ).toThrow(/must be newer/);
+    }
+  });
+
+  it("writes GitHub Actions outputs when run as a command", () => {
+    const tempDirectory = mkdtempSync(join(tmpdir(), 
"ts-sdk-release-inputs-"));
+    const outputFile = join(tempDirectory, "github-output");
+    try {
+      const result = spawnSync(
+        process.execPath,
+        [fileURLToPath(import.meta.resolve("./validate-release-inputs.mjs"))],
+        {
+          encoding: "utf8",
+          env: {
+            ...process.env,
+            GITHUB_OUTPUT: outputFile,
+            NPM_TAG: "beta",
+            RELEASE_TAG: "ts-sdk/1.0.0-beta1",
+          },
+        },
+      );
+      expect(result.status, result.stderr).toBe(0);
+      expect(readFileSync(outputFile, "utf8")).toBe(
+        "version=1.0.0-beta1\npackage_name=apache-airflow-ts-sdk\n" +
+          "package_file=apache-airflow-ts-sdk-1.0.0-beta1.tgz\n",
+      );
+    } finally {
+      rmSync(tempDirectory, { force: true, recursive: true });
+    }
+  });
+});

Reply via email to