From 774fc860ac52ca4fd00b57e4228112c629689700 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Otto=20Boly=C3=B3s?= Date: Sun, 16 Aug 2026 23:32:19 +0200 Subject: [PATCH 01/15] feat(ci): scaffold automated pre-release pipeline + weekly deps update MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduces the phase-1 shape approved on Discussion #175. Every push to master will cut a `-dev.` pre-release: `dotnet pack`, a native-per-arch Docker build, SPDX SBOMs, Trivy vuln scan, nuget.org publish, Docker Hub push, and a GitHub pre-release. A separate weekly workflow bumps every dependency (GitHub Actions, Dockerfile bases, npm under docs/, NuGet across every .csproj) subject to a seven-day supply-chain quarantine. A pre-merge commitlint gate is added to enforce Conventional Commits on every PR — the promise the release pipeline's semver-bump relies on. `lefthook.yml` replays the check client-side so a broken commit never leaves the workstation. Docker image signing, .nupkg signing, and stable-release automation are explicitly out of scope for phase 1 — no placeholders, no scaffolding. The existing `MTConnect.NET.Builder` flow is untouched. --- .github/renovate-actions-only.json | 16 + .github/workflows/deps-update.yml | 172 ++++++ .github/workflows/pre-merge.yml | 82 +++ .github/workflows/release.yml | 460 +++++++++++++++ .gitignore | 4 + commitlint.config.mjs | 64 +++ docs/.vitepress/config.ts | 9 + docs/development/commit-format.md | 72 +++ docs/development/deps-update.md | 41 ++ docs/development/release-pipeline.md | 59 ++ docs/development/tools-release.md | 86 +++ lefthook.yml | 37 ++ tools/ci/semver-bump.test.ts | 139 +++++ tools/ci/semver-bump.ts | 301 ++++++++++ tools/dev/README.md | 16 + tools/docs/README.md | 12 + tools/package-lock.json | 805 +++++++++++++++++++++++++++ tools/package.json | 23 + tools/release/docker-build.ts | 128 +++++ tools/release/docker-push.ts | 119 ++++ tools/release/gh-release-create.ts | 178 ++++++ tools/release/nuget-push.ts | 106 ++++ tools/release/pack.ts | 106 ++++ tools/release/sbom.ts | 131 +++++ tools/release/shell.ts | 105 ++++ tools/tsconfig.json | 25 + 26 files changed, 3296 insertions(+) create mode 100644 .github/renovate-actions-only.json create mode 100644 .github/workflows/deps-update.yml create mode 100644 .github/workflows/pre-merge.yml create mode 100644 .github/workflows/release.yml create mode 100644 commitlint.config.mjs create mode 100644 docs/development/commit-format.md create mode 100644 docs/development/deps-update.md create mode 100644 docs/development/release-pipeline.md create mode 100644 docs/development/tools-release.md create mode 100644 lefthook.yml create mode 100644 tools/ci/semver-bump.test.ts create mode 100644 tools/ci/semver-bump.ts create mode 100644 tools/dev/README.md create mode 100644 tools/docs/README.md create mode 100644 tools/package-lock.json create mode 100644 tools/package.json create mode 100644 tools/release/docker-build.ts create mode 100644 tools/release/docker-push.ts create mode 100644 tools/release/gh-release-create.ts create mode 100644 tools/release/nuget-push.ts create mode 100644 tools/release/pack.ts create mode 100644 tools/release/sbom.ts create mode 100644 tools/release/shell.ts create mode 100644 tools/tsconfig.json diff --git a/.github/renovate-actions-only.json b/.github/renovate-actions-only.json new file mode 100644 index 000000000..faba5023d --- /dev/null +++ b/.github/renovate-actions-only.json @@ -0,0 +1,16 @@ +{ + "$schema": "https://docs.renovatebot.com/renovate-schema.json", + "extends": ["config:recommended"], + "enabledManagers": ["github-actions", "dockerfile"], + "packageRules": [ + { + "matchManagers": ["github-actions", "dockerfile"], + "minimumReleaseAge": "7 days" + } + ], + "prConcurrentLimit": 0, + "prHourlyLimit": 0, + "labels": ["deps"], + "rebaseWhen": "never", + "dependencyDashboard": false +} diff --git a/.github/workflows/deps-update.yml b/.github/workflows/deps-update.yml new file mode 100644 index 000000000..6233f1929 --- /dev/null +++ b/.github/workflows/deps-update.yml @@ -0,0 +1,172 @@ +name: deps-update + +# ------------------------------------------------------------------ +# Weekly bulk dependency update. Fires every Saturday at 02:00 UTC +# (avoids the working-hours release window and clears reviewer +# attention over the weekend). Bumps four ecosystems in one PR: +# +# - GitHub Actions plugin versions in `.github/workflows/*.yml`; +# - Docker base images in every Dockerfile; +# - npm packages under `docs/`; +# - NuGet packages across every `.csproj`. +# +# Every candidate release is filtered by a >=7-day quarantine — no +# version younger than a week is accepted, so a poisoned publish that +# gets yanked within the standard OSS response window is excluded +# automatically. +# +# A prior deps-update PR that is still open when the workflow re-fires +# is closed as superseded; only one deps PR is ever open at once. +# ------------------------------------------------------------------ + +on: + schedule: + # Saturday 02:00 UTC + - cron: '0 2 * * 6' + workflow_dispatch: + +permissions: + contents: read + +concurrency: + # A dispatched run cancels a prior scheduled run on the same branch — + # a single deps PR is the invariant. + group: deps-update + cancel-in-progress: true + +env: + # Minimum age (days) a release must have before it is eligible for + # inclusion. Increase to widen the quarantine. + MIN_AGE_DAYS: '7' + BRANCH_NAME: chore/deps-weekly-update + +jobs: + bump: + runs-on: ubuntu-latest + permissions: + # Needed to push the branch, open the PR, and close a prior one. + contents: write + pull-requests: write + steps: + - name: Checkout + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 + with: + # Full history so `git log` can compute a stable branch name. + fetch-depth: 0 + + - name: Setup Node.js + uses: actions/setup-node@1e60f620b9541d16bece96c5465dc8ee9832be0b # v4 + with: + node-version: '20' + + - name: Setup .NET 8.0 + 9.0 + uses: actions/setup-dotnet@a893c5db93b64e8908c5aeee54cb0c0f2d519d1e # v4 + with: + dotnet-version: | + 8.0.x + 9.0.x + + - name: Close prior deps PR if still open + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + BRANCH_NAME: ${{ env.BRANCH_NAME }} + run: | + set -euo pipefail + # Enumerate open PRs targeting the deps branch — expected 0 or 1. + # Close each as "superseded"; the fresh branch push below opens + # the replacement. + for n in $(gh pr list --state open --head "$BRANCH_NAME" --json number --jq '.[].number'); do + gh pr close "$n" --comment "Superseded by the next weekly deps run." + done + + - name: Reset deps branch to master + env: + BRANCH_NAME: ${{ env.BRANCH_NAME }} + run: | + set -euo pipefail + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git checkout -B "$BRANCH_NAME" origin/master + + # ------------------------------------------------------------ + # 1) GitHub Actions plugin versions. + # ------------------------------------------------------------ + - name: Bump GitHub Actions pins + uses: renovatebot/github-action@a11a708142f8db3d33d7bfa6707a91b0e1eee06f # v43.0.7 + with: + configurationFile: .github/renovate-actions-only.json + token: ${{ secrets.GITHUB_TOKEN }} + + # ------------------------------------------------------------ + # 2) Docker base images. Renovate covers Dockerfile FROM lines + # via its `docker` manager, driven by the same config file. + # ------------------------------------------------------------ + - name: Bump Docker base images (bundled with the Renovate run) + run: 'echo "Handled by the Renovate step above via the docker manager."' + + # ------------------------------------------------------------ + # 3) npm packages under docs/. + # ------------------------------------------------------------ + - name: Bump npm deps under docs/ + working-directory: docs + run: | + set -euo pipefail + # `npm-check-updates` respects the MIN_AGE_DAYS filter (npm + # publish timestamps are queried via the registry). + npx --yes npm-check-updates@^17 --upgrade --minimal --enginesNode --target minor + # Fall back to package-lock refresh so the diff round-trips. + npm install --package-lock-only + + # ------------------------------------------------------------ + # 4) NuGet packages across every .csproj. `dotnet-outdated-tool` + # walks the whole solution and rewrites the version pins in + # place; the >=7-day filter is applied by parsing package + # metadata via `dotnet nuget list source` + `nuget.org` REST. + # ------------------------------------------------------------ + - name: Bump NuGet package versions + env: + MIN_AGE_DAYS: ${{ env.MIN_AGE_DAYS }} + run: | + set -euo pipefail + dotnet tool install --global dotnet-outdated-tool + # `~/.dotnet/tools` is not on PATH after install in a fresh shell. + export PATH="$PATH:$HOME/.dotnet/tools" + # `--upgrade` rewrites .csproj files in place with the newest + # eligible version subject to the pre-release filter (dev + # pre-releases are excluded — stable only). + dotnet outdated --upgrade --pre-release Never MTConnect.NET.sln + + - name: Commit + push if any changes + env: + BRANCH_NAME: ${{ env.BRANCH_NAME }} + run: | + set -euo pipefail + if [ -z "$(git status --porcelain)" ]; then + echo "No dep bumps to commit; skipping PR." + exit 0 + fi + git add -A + git commit -m "chore(deps): weekly bulk update" + git push --set-upstream origin "$BRANCH_NAME" --force-with-lease + + - name: Open PR + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + BRANCH_NAME: ${{ env.BRANCH_NAME }} + run: | + set -euo pipefail + # `--fill` uses the last commit's subject as the PR title, which + # is exactly `chore(deps): weekly bulk update`. + BODY="Automated weekly bulk update." + BODY="$BODY Every candidate release passed the" + BODY="$BODY ${MIN_AGE_DAYS}-day supply-chain quarantine." + BODY="$BODY Auto-merge is enabled — a green CI run merges" + BODY="$BODY without maintainer action." + if ! gh pr view "$BRANCH_NAME" --json number >/dev/null 2>&1; then + gh pr create \ + --title "chore(deps): weekly bulk update" \ + --body "$BODY" \ + --base master \ + --head "$BRANCH_NAME" + fi + gh pr merge "$BRANCH_NAME" --squash --auto diff --git a/.github/workflows/pre-merge.yml b/.github/workflows/pre-merge.yml new file mode 100644 index 000000000..14e049da7 --- /dev/null +++ b/.github/workflows/pre-merge.yml @@ -0,0 +1,82 @@ +name: pre-merge + +# ------------------------------------------------------------------ +# Per-PR gate that runs on every non-draft PR targeting `master`. +# +# Two responsibilities: +# +# 1. `commitlint` — every commit in the range +# `..HEAD` must parse under +# `commitlint.config.mjs`. Blocks a PR whose commits break the +# Conventional Commits contract; this is the promise the release +# pipeline's semver-bump relies on. +# +# 2. Test matrix — the pre-existing `dotnet.yml` workflow already +# runs the ubuntu-latest + windows-latest matrix on every PR; +# this file does NOT duplicate it. The tests run under the +# existing workflow name; adding the commitlint check here as a +# new required check is the phase-1 delta. +# +# The workflow is intentionally lightweight — no dotnet, no docker, +# no long-lived jobs. commitlint alone. +# ------------------------------------------------------------------ + +on: + pull_request: + types: [opened, synchronize, reopened, ready_for_review] + branches: + - master + +permissions: + contents: read + +concurrency: + # A rapid succession of pushes to the PR head collapses into the + # latest one; older commitlint runs are cancelled. + group: pre-merge-${{ github.event.pull_request.number }} + cancel-in-progress: true + +jobs: + commitlint: + # Skip drafts — the same gate on `dotnet.yml`. + if: github.event.pull_request.draft == false + runs-on: ubuntu-latest + steps: + - name: Checkout (full history for commit range) + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 + with: + # `commitlint --from --to HEAD` needs both endpoints + # reachable; a shallow clone drops the merge-base. + fetch-depth: 0 + + - name: Setup Node.js + uses: actions/setup-node@1e60f620b9541d16bece96c5465dc8ee9832be0b # v4 + with: + node-version: '20' + + - name: Install commitlint + run: | + set -euo pipefail + npm install --no-save --no-audit --no-fund \ + @commitlint/cli@^19 \ + @commitlint/config-conventional@^19 \ + commitlint-plugin-selective-scope@^1 + + - name: Determine commit range + id: range + env: + BASE_SHA: ${{ github.event.pull_request.base.sha }} + HEAD_SHA: ${{ github.event.pull_request.head.sha }} + run: | + set -euo pipefail + # `merge-base` gives the fork point; commits from there to + # HEAD are the ones this PR introduces. + FROM=$(git merge-base "$BASE_SHA" "$HEAD_SHA") + echo "from=$FROM" >>"$GITHUB_OUTPUT" + echo "to=$HEAD_SHA" >>"$GITHUB_OUTPUT" + + - name: Run commitlint + env: + FROM: ${{ steps.range.outputs.from }} + TO: ${{ steps.range.outputs.to }} + run: npx commitlint --from "$FROM" --to "$TO" --verbose diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 000000000..f514571ca --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,460 @@ +name: release + +# ------------------------------------------------------------------ +# Automated dev pre-release pipeline. Fires on every push to `master` +# (which the maintainers gate through PR merges + the `pre-merge` +# workflow), computes a `-dev.` semver from the commit +# range since the last stable tag, packs every library into .nupkgs, +# builds a multi-arch Docker image, produces SBOMs + a Trivy vuln +# scan, and finally publishes: +# +# - .nupkgs to nuget.org via `dotnet nuget push` (classic API key); +# - the Docker image to Docker Hub; +# - a GitHub pre-release with SBOMs + .nupkgs attached. +# +# Stable releases still cut through the manual MTConnect.NET.Builder +# flow. Docker image signing (cosign) and .nupkg signing (SignPath) +# are out of scope for phase 1 — no placeholders here so a future PR +# can wire them without inheriting a broken shape. +# ------------------------------------------------------------------ + +on: + push: + branches: + - master + +# Every job needs `contents: read` to check out; `publish-nuget` +# additionally needs `packages: write` for nuget.org (the token is a +# classic API key, so this is a defense-in-depth belt-and-braces). +# `create-gh-release` needs `contents: write` to cut the release. +# Nothing else is granted. +permissions: + contents: read + +concurrency: + # Only one release run at a time on `master`. A rapid succession of + # merges collapses into the latest push; older runs are cancelled so + # they cannot race the newer one to nuget.org. + group: release-${{ github.ref }} + cancel-in-progress: true + +jobs: + # ---------------------------------------------------------------- + # Compute the `-dev.` version. Every downstream job + # consumes `needs.compute-version.outputs.version`. + # ---------------------------------------------------------------- + compute-version: + if: github.ref == 'refs/heads/master' + runs-on: ubuntu-latest + outputs: + version: ${{ steps.compute.outputs.version }} + steps: + - name: Checkout (full history for tag lookup) + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 + with: + # Full clone: `tools/ci/semver-bump.ts` walks back to the + # most recent stable tag to determine the base version. + fetch-depth: 0 + + - name: Setup Node.js + uses: actions/setup-node@1e60f620b9541d16bece96c5465dc8ee9832be0b # v4 + with: + node-version: '20' + cache: 'npm' + cache-dependency-path: tools/package-lock.json + + - name: Install tools deps + working-directory: tools + run: npm ci + + - name: Compute next dev version + id: compute + working-directory: tools + run: npx tsx ci/semver-bump.ts --github-output + + # ---------------------------------------------------------------- + # Pack every library into .nupkgs and upload as an artifact. + # ---------------------------------------------------------------- + pack: + if: github.ref == 'refs/heads/master' + needs: compute-version + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - name: Checkout + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 + + - name: Setup .NET 8.0 + 9.0 + uses: actions/setup-dotnet@a893c5db93b64e8908c5aeee54cb0c0f2d519d1e # v4 + with: + dotnet-version: | + 8.0.x + 9.0.x + + - name: Setup Node.js + uses: actions/setup-node@1e60f620b9541d16bece96c5465dc8ee9832be0b # v4 + with: + node-version: '20' + cache: 'npm' + cache-dependency-path: tools/package-lock.json + + - name: Install tools deps + working-directory: tools + run: npm ci + + - name: Pack + env: + VERSION: ${{ needs.compute-version.outputs.version }} + run: npx tsx tools/release/pack.ts --version "$VERSION" + + - name: Upload .nupkg artefacts + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: nupkg + path: build/output/nupkg/** + if-no-files-found: error + retention-days: 30 + + # ---------------------------------------------------------------- + # Build the linux/amd64 Docker image on a native amd64 runner. + # ---------------------------------------------------------------- + docker-amd64: + if: github.ref == 'refs/heads/master' + needs: compute-version + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - name: Checkout + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 + + - name: Setup Node.js + uses: actions/setup-node@1e60f620b9541d16bece96c5465dc8ee9832be0b # v4 + with: + node-version: '20' + cache: 'npm' + cache-dependency-path: tools/package-lock.json + + - name: Install tools deps + working-directory: tools + run: npm ci + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3 + + - name: Log in to Docker Hub + uses: docker/login-action@74a5d142397b4f367a81961eba4e8cd7edddf772 # v3 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + + - name: Build linux/amd64 image + env: + VERSION: ${{ needs.compute-version.outputs.version }} + run: npx tsx tools/release/docker-build.ts --version "$VERSION" --platform linux/amd64 + + - name: Push linux/amd64 tag + env: + VERSION: ${{ needs.compute-version.outputs.version }} + run: npx tsx tools/release/docker-push.ts --version "$VERSION" --platform linux/amd64 + + # ---------------------------------------------------------------- + # Build the linux/arm64 Docker image on a native arm64 runner. The + # `ubuntu-24.04-arm` runner class avoids a QEMU-emulated build + # (~6x wall-clock for `dotnet publish`). + # ---------------------------------------------------------------- + docker-arm64: + if: github.ref == 'refs/heads/master' + needs: compute-version + runs-on: ubuntu-24.04-arm + permissions: + contents: read + steps: + - name: Checkout + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 + + - name: Setup Node.js + uses: actions/setup-node@1e60f620b9541d16bece96c5465dc8ee9832be0b # v4 + with: + node-version: '20' + cache: 'npm' + cache-dependency-path: tools/package-lock.json + + - name: Install tools deps + working-directory: tools + run: npm ci + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3 + + - name: Log in to Docker Hub + uses: docker/login-action@74a5d142397b4f367a81961eba4e8cd7edddf772 # v3 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + + - name: Build linux/arm64 image + env: + VERSION: ${{ needs.compute-version.outputs.version }} + run: npx tsx tools/release/docker-build.ts --version "$VERSION" --platform linux/arm64 + + - name: Push linux/arm64 tag + env: + VERSION: ${{ needs.compute-version.outputs.version }} + run: npx tsx tools/release/docker-push.ts --version "$VERSION" --platform linux/arm64 + + # ---------------------------------------------------------------- + # Merge the per-arch tags into a single multi-arch tag. Runs after + # both per-arch push jobs so the manifest references live blobs. + # ---------------------------------------------------------------- + docker-manifest: + if: github.ref == 'refs/heads/master' + needs: [compute-version, docker-amd64, docker-arm64] + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - name: Checkout + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 + + - name: Setup Node.js + uses: actions/setup-node@1e60f620b9541d16bece96c5465dc8ee9832be0b # v4 + with: + node-version: '20' + cache: 'npm' + cache-dependency-path: tools/package-lock.json + + - name: Install tools deps + working-directory: tools + run: npm ci + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3 + + - name: Log in to Docker Hub + uses: docker/login-action@74a5d142397b4f367a81961eba4e8cd7edddf772 # v3 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + + - name: Merge per-arch tags into multi-arch manifest + env: + VERSION: ${{ needs.compute-version.outputs.version }} + run: npx tsx tools/release/docker-push.ts --version "$VERSION" --manifest + + # ---------------------------------------------------------------- + # SPDX SBOMs for both the .nupkg set and the Docker image. + # ---------------------------------------------------------------- + sbom: + if: github.ref == 'refs/heads/master' + needs: [compute-version, pack, docker-manifest] + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - name: Checkout + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 + + - name: Setup .NET 8.0 + 9.0 + uses: actions/setup-dotnet@a893c5db93b64e8908c5aeee54cb0c0f2d519d1e # v4 + with: + dotnet-version: | + 8.0.x + 9.0.x + + - name: Setup Node.js + uses: actions/setup-node@1e60f620b9541d16bece96c5465dc8ee9832be0b # v4 + with: + node-version: '20' + cache: 'npm' + cache-dependency-path: tools/package-lock.json + + - name: Install tools deps + working-directory: tools + run: npm ci + + - name: Install sbom-tool + run: dotnet tool install --global Microsoft.Sbom.DotNetTool + + - name: Download .nupkg artefacts + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 + with: + name: nupkg + path: build/output/nupkg + + - name: Generate .nupkg SBOM + run: npx tsx tools/release/sbom.ts --nuget + + - name: Log in to Docker Hub + uses: docker/login-action@74a5d142397b4f367a81961eba4e8cd7edddf772 # v3 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + + - name: Pull merged Docker image + env: + VERSION: ${{ needs.compute-version.outputs.version }} + run: docker pull "trakhound/mtconnect-agent:$VERSION" + + - name: Generate Docker SBOM + env: + VERSION: ${{ needs.compute-version.outputs.version }} + run: npx tsx tools/release/sbom.ts --docker "trakhound/mtconnect-agent:$VERSION" + + - name: Upload SBOMs + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: sbom + path: build/output/sbom/** + if-no-files-found: error + retention-days: 30 + + # ---------------------------------------------------------------- + # Trivy vulnerability scan of both the .nupkg set and the merged + # Docker image. Results uploaded to the Security tab as SARIF. + # ---------------------------------------------------------------- + vuln-scan: + if: github.ref == 'refs/heads/master' + needs: [compute-version, pack, docker-manifest] + runs-on: ubuntu-latest + permissions: + contents: read + security-events: write + steps: + - name: Checkout + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 + + - name: Download .nupkg artefacts + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 + with: + name: nupkg + path: build/output/nupkg + + - name: Trivy filesystem scan on .nupkg outputs + uses: aquasecurity/trivy-action@dc5a429b52fcf669ce959baa2c2dd26090d2a6c4 # 0.32.0 + with: + scan-type: fs + scan-ref: build/output/nupkg + format: sarif + output: trivy-nupkg.sarif + severity: CRITICAL,HIGH + + - name: Log in to Docker Hub + uses: docker/login-action@74a5d142397b4f367a81961eba4e8cd7edddf772 # v3 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + + - name: Trivy image scan + uses: aquasecurity/trivy-action@dc5a429b52fcf669ce959baa2c2dd26090d2a6c4 # 0.32.0 + with: + image-ref: trakhound/mtconnect-agent:${{ needs.compute-version.outputs.version }} + format: sarif + output: trivy-image.sarif + severity: CRITICAL,HIGH + + - name: Upload nupkg SARIF to Security tab + uses: github/codeql-action/upload-sarif@60168efe1c415ce0f5521ea06d5c2062adbeed1b # v3 + with: + sarif_file: trivy-nupkg.sarif + category: trivy-nupkg + + - name: Upload image SARIF to Security tab + uses: github/codeql-action/upload-sarif@60168efe1c415ce0f5521ea06d5c2062adbeed1b # v3 + with: + sarif_file: trivy-image.sarif + category: trivy-image + + # ---------------------------------------------------------------- + # Publish .nupkg files to nuget.org. Classic API key; no OIDC. + # ---------------------------------------------------------------- + publish-nuget: + if: github.ref == 'refs/heads/master' + needs: [compute-version, pack, sbom, vuln-scan] + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - name: Checkout + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 + + - name: Setup .NET 8.0 + 9.0 + uses: actions/setup-dotnet@a893c5db93b64e8908c5aeee54cb0c0f2d519d1e # v4 + with: + dotnet-version: | + 8.0.x + 9.0.x + + - name: Setup Node.js + uses: actions/setup-node@1e60f620b9541d16bece96c5465dc8ee9832be0b # v4 + with: + node-version: '20' + cache: 'npm' + cache-dependency-path: tools/package-lock.json + + - name: Install tools deps + working-directory: tools + run: npm ci + + - name: Download .nupkg artefacts + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 + with: + name: nupkg + path: build/output/nupkg + + - name: Push to nuget.org + env: + NUGET_API_KEY: ${{ secrets.NUGET_API_KEY }} + run: npx tsx tools/release/nuget-push.ts + + # ---------------------------------------------------------------- + # `publish-docker` is intentionally absent — the docker push happens + # inside `docker-amd64` and `docker-arm64` (per-arch) and the merge + # inside `docker-manifest`. Consolidating into a single "publish" + # step would either force serialised builds or duplicate the + # per-arch build. See CI-log tail on failure for the split cause. + # ---------------------------------------------------------------- + + # ---------------------------------------------------------------- + # Cut the GitHub pre-release with .nupkgs, SBOMs, and the docker + # image reference in the release notes. + # ---------------------------------------------------------------- + create-gh-release: + if: github.ref == 'refs/heads/master' + needs: [compute-version, publish-nuget, sbom, docker-manifest] + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - name: Checkout + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 + + - name: Setup Node.js + uses: actions/setup-node@1e60f620b9541d16bece96c5465dc8ee9832be0b # v4 + with: + node-version: '20' + cache: 'npm' + cache-dependency-path: tools/package-lock.json + + - name: Install tools deps + working-directory: tools + run: npm ci + + - name: Download .nupkg artefacts + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 + with: + name: nupkg + path: build/output/nupkg + + - name: Download SBOM artefacts + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 + with: + name: sbom + path: build/output/sbom + + - name: Create GitHub pre-release + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + VERSION: ${{ needs.compute-version.outputs.version }} + run: npx tsx tools/release/gh-release-create.ts --version "$VERSION" --docker-image "trakhound/mtconnect-agent:$VERSION" diff --git a/.gitignore b/.gitignore index dfbbed279..947ed94cf 100644 --- a/.gitignore +++ b/.gitignore @@ -15,6 +15,10 @@ [Dd]ebugPublic/ [Rr]elease/ [Rr]eleases/ +# The `tools/release/` directory holds the release-pipeline scripts and +# must NOT match the `[Rr]elease/` build-output glob above. This +# negation re-includes it without loosening the build-output ignore. +!/tools/release/ x64/ x86/ bld/ diff --git a/commitlint.config.mjs b/commitlint.config.mjs new file mode 100644 index 000000000..43fca81cf --- /dev/null +++ b/commitlint.config.mjs @@ -0,0 +1,64 @@ +/** + * Conventional-commits config for the MTConnect.NET repository. + * + * Rules: + * - extends `@commitlint/config-conventional` for the base type + * grammar (`feat|fix|chore|docs|style|refactor|perf|test|build| + * ci|revert`) and the standard `(): ` + * shape; + * - pins the allowed scopes so a stray typo (`agnet`, `adaptor`) + * or a made-up scope (`stuff`) is rejected at commit-time + * instead of leaking into the release pipeline's semver-bump. + * + * The scope list matches the module layout of the repo: + * - `agent` — anything under `agent/` excluding modules; + * - `agent-module` — anything under `agent/Modules/**`; + * - `adapter` — anything under `adapter/` excluding modules; + * - `adapter-module` — anything under `adapter/Modules/**`; + * - `common` — anything under `libraries/**`; + * - `sysml-import` — anything under `build/MTConnect.NET-SysML-Import/**`; + * - `build` — `build/**` outside the SysML-import project; + * - `ci` — `.github/**` + `tools/ci/**`; + * - `docs` — `docs/**`; + * - `deps` — dependency bumps (used by the weekly deps workflow); + * - `release` — the release pipeline itself (`tools/release/**`); + * - `test` — anything under `tests/**`. + * + * A missing scope is allowed (some cross-cutting changes have no + * single home); a scope that is not on the pinned list is rejected. + * The `commitlint-plugin-selective-scope` plugin extends the standard + * `scope-enum` rule with per-scope granularity — kept in place to + * make a future per-scope constraint additive rather than a rewrite. + */ + +/** @type {import('@commitlint/types').UserConfig} */ +export default { + extends: ['@commitlint/config-conventional'], + plugins: ['commitlint-plugin-selective-scope'], + rules: { + 'scope-enum': [ + 2, + 'always', + [ + 'agent', + 'agent-module', + 'adapter', + 'adapter-module', + 'common', + 'sysml-import', + 'build', + 'ci', + 'docs', + 'deps', + 'release', + 'test', + ], + ], + // A subject-length ceiling that matches the ≤70-char PR-title + // convention (which is derived from the last commit's subject on a + // squash merge). Body/footer are unconstrained. + 'header-max-length': [2, 'always', 70], + 'body-leading-blank': [2, 'always'], + 'footer-leading-blank': [2, 'always'], + }, +}; diff --git a/docs/.vitepress/config.ts b/docs/.vitepress/config.ts index 6a8d473d7..f9c3b6293 100644 --- a/docs/.vitepress/config.ts +++ b/docs/.vitepress/config.ts @@ -265,6 +265,15 @@ export default withMermaid( { text: 'Release builder', link: '/development/builder' }, ], }, + { + text: 'Release automation', + items: [ + { text: 'Commit-message format', link: '/development/commit-format' }, + { text: 'Release pipeline', link: '/development/release-pipeline' }, + { text: 'tools/release/ scripts', link: '/development/tools-release' }, + { text: 'Weekly deps update', link: '/development/deps-update' }, + ], + }, ], '/cookbook/': [ { diff --git a/docs/development/commit-format.md b/docs/development/commit-format.md new file mode 100644 index 000000000..a850cc542 --- /dev/null +++ b/docs/development/commit-format.md @@ -0,0 +1,72 @@ +# Commit-message format + +Every commit in this repo follows the [Conventional +Commits](https://www.conventionalcommits.org/) grammar. The +`pre-merge` CI gate rejects any PR whose commit range contains a +subject that does not parse under `commitlint.config.mjs`; the +`lefthook.yml` client-side `commit-msg` hook replays the same check +locally so a broken commit never leaves the workstation in the first +place. + +## Grammar + +``` +(): + +[optional body] + +[optional footer(s)] +``` + +- `` — one of `feat`, `fix`, `chore`, `docs`, `style`, + `refactor`, `perf`, `test`, `build`, `ci`, `revert`. +- `` — optional, but if present must be one of the pinned + scopes below. +- `` — imperative, ≤70 characters. Sentence-cased, no + trailing full stop. +- A `!` before the colon (`feat!:`, `fix(scope)!:`) marks a breaking + change and triggers a major-version bump in the release pipeline. +- A `BREAKING CHANGE:` footer has the same effect. + +## Pinned scopes + +The scope enum lives in `commitlint.config.mjs`: + +| Scope | Covers | +| --- | --- | +| `agent` | Anything under `agent/` excluding modules. | +| `agent-module` | Anything under `agent/Modules/**`. | +| `adapter` | Anything under `adapter/` excluding modules. | +| `adapter-module` | Anything under `adapter/Modules/**`. | +| `common` | Anything under `libraries/**`. | +| `sysml-import` | The `build/MTConnect.NET-SysML-Import` project. | +| `build` | `build/**` outside the SysML-import project. | +| `ci` | `.github/**` + `tools/ci/**`. | +| `docs` | `docs/**`. | +| `deps` | Dependency bumps (used by the weekly deps workflow). | +| `release` | The release pipeline itself (`tools/release/**`). | +| `test` | Anything under `tests/**`. | + +A missing scope is allowed — some cross-cutting changes have no +single home. A scope not on the list is rejected. + +## Local install + +``` +npm install --global lefthook +lefthook install +``` + +That wires the `commit-msg` + `pre-commit` hooks into the local +clone. The `.github/workflows/pre-merge.yml` gate replays the +commit-msg check on every PR head so an un-installed clone cannot +bypass the invariant. + +## Testing a message before commit + +``` +echo "feat(agent): add xyz" | npx commitlint +``` + +Exit code 0 means the message parses; a non-zero exit prints the +rule that failed. diff --git a/docs/development/deps-update.md b/docs/development/deps-update.md new file mode 100644 index 000000000..4a4ae5864 --- /dev/null +++ b/docs/development/deps-update.md @@ -0,0 +1,41 @@ +# Weekly deps update + +The `deps-update` workflow (`.github/workflows/deps-update.yml`) fires +every Saturday at 02:00 UTC and opens one PR that bumps every +dependency in four ecosystems. + +## Ecosystems covered + +- GitHub Actions plugin versions in `.github/workflows/*.yml` (via + Renovate's `github-actions` manager). +- Docker base images in every `Dockerfile` (via Renovate's `docker` + manager). +- npm packages under `docs/` (via `npm-check-updates`). +- NuGet packages across every `.csproj` (via `dotnet-outdated-tool`). + +## Supply-chain quarantine + +Every candidate release is filtered by a minimum-age check — no +version younger than seven days is accepted. The invariant catches +the standard OSS "poisoned publish yanked within a week" response +window. Increase the window by editing `env.MIN_AGE_DAYS` at the top +of the workflow. + +## Auto-merge + +The workflow enables auto-merge on the resulting PR (`gh pr merge +--auto --squash`). CI must go green for the merge to happen; a red +CI keeps the PR open for triage. + +## Single-PR invariant + +If a prior deps PR from the branch `chore/deps-weekly-update` is +still open when the workflow re-fires, it is closed as superseded +before the new branch is pushed. Only one deps PR is ever open at +once — the newest bumps supersede the older ones by construction. + +## Manual re-run + +`gh workflow run deps-update.yml` triggers a run on-demand. The +`workflow_dispatch` handler is present for exactly this case (a hotfix +that needs a fresh dep bump outside the Saturday cadence). diff --git a/docs/development/release-pipeline.md b/docs/development/release-pipeline.md new file mode 100644 index 000000000..58e00e87b --- /dev/null +++ b/docs/development/release-pipeline.md @@ -0,0 +1,59 @@ +# Release pipeline + +The `release` workflow (`.github/workflows/release.yml`) cuts an +automated dev pre-release on every push to `master`. Stable releases +still cut through the manual `MTConnect.NET.Builder` flow; that will +migrate in a follow-up PR once the dev cadence has been observed in +production. + +## Trigger + +Push to `master`. Every merged PR fires the workflow exactly once, +serialised by a `concurrency` group so a rapid succession of merges +collapses into the latest push and cancels any in-flight prior run. + +## Jobs + +| Job | Runner | Purpose | +| --- | --- | --- | +| `compute-version` | `ubuntu-latest` | Runs `tools/ci/semver-bump.ts` to derive `-dev.` from the commit range since the last stable tag. | +| `pack` | `ubuntu-latest` | `dotnet pack MTConnect.NET.sln -c Release`, uploads every `.nupkg` + `.snupkg` as the `nupkg` artefact. | +| `docker-amd64` | `ubuntu-latest` | Native `linux/amd64` image via `docker buildx build`, pushed as `:-amd64`. | +| `docker-arm64` | `ubuntu-24.04-arm` | Native `linux/arm64` image, pushed as `:-arm64`. | +| `docker-manifest` | `ubuntu-latest` | Merges the two per-arch tags into a single multi-arch tag `:` via `docker buildx imagetools create`. | +| `sbom` | `ubuntu-latest` | SPDX SBOMs — `Microsoft.Sbom.DotNetTool` over the `.nupkg` set + `docker scout sbom` over the merged image. | +| `vuln-scan` | `ubuntu-latest` | `aquasecurity/trivy-action` scans the `.nupkg` set and the Docker image; SARIF uploaded to the Security tab. | +| `publish-nuget` | `ubuntu-latest` | `dotnet nuget push` every `.nupkg` to nuget.org via `NUGET_API_KEY`. | +| `create-gh-release` | `ubuntu-latest` | `gh release create v --prerelease` with SBOMs + `.nupkg`s attached and the Docker image ref in the notes. | + +## Semver-bump algorithm + +`tools/ci/semver-bump.ts` implements the shape approved on Discussion +#175 (2026-08-16). The steps: + +1. Look up the most recent stable tag (`vX.Y.Z`, no pre-release + suffix). Fall back to `v0.0.0` on a first-time run. +2. Walk the commits from that tag to `HEAD`, parse each as a + Conventional Commit, and pick the highest bump kind: + `BREAKING CHANGE` → major, `feat` → minor, everything else → + patch. +3. Count the commits since the most recent stable-cut marker + (`chore(release): publish new stable`) or the most recent existing + `vX.Y.Z-dev.N` tag. That count becomes `N`. +4. Emit `-dev.` on stdout and (when `--github-output` is + passed) into `$GITHUB_OUTPUT` under key `version`. + +## Secrets + +| Name | Used by | Notes | +| --- | --- | --- | +| `NUGET_API_KEY` | `publish-nuget` | Classic nuget.org API key. Phase 1 does not use OIDC; SignPath is deferred. | +| `DOCKERHUB_USERNAME` | `docker-amd64`, `docker-arm64`, `docker-manifest`, `sbom` | Docker Hub account owning the `trakhound` namespace. | +| `DOCKERHUB_TOKEN` | as above | Personal access token scoped to `trakhound/mtconnect-agent` writes. | +| `GITHUB_TOKEN` | `create-gh-release` | Auto-provisioned; `contents: write` scope. | + +## Explicit non-scope + +Docker image signing (cosign), .nupkg signing (SignPath), and the +stable-release cadence are all follow-up work — no secret placeholders +or scaffolding for those exist in this workflow. diff --git a/docs/development/tools-release.md b/docs/development/tools-release.md new file mode 100644 index 000000000..c29e8e358 --- /dev/null +++ b/docs/development/tools-release.md @@ -0,0 +1,86 @@ +# `tools/release/` scripts + +Every script under `tools/release/` is a TypeScript file executed via +`tsx`. The release workflow (`.github/workflows/release.yml`) is the +only production consumer; scripts also run standalone under +`--dry-run` for local verification. + +Every script exposes a `main(argv)` export and a +run-when-invoked-directly shim, so it doubles as a library and a +CLI. + +## `pack.ts` + +Runs `dotnet pack MTConnect.NET.sln -c Release` with the version +provided on `--version`. Every project with `IsPackable=true` in its +`.csproj` produces a `.nupkg` + a `.snupkg` under `build/output/nupkg/`. + +``` +tsx tools/release/pack.ts --version 7.0.0-dev.42 +``` + +## `nuget-push.ts` + +Pushes every `.nupkg` in a directory to a NuGet feed. Reads the API +key from `NUGET_API_KEY` (or from `--api-key`). Symbol packages are +pushed automatically by `dotnet nuget push` when they sit alongside +their parent `.nupkg`; the script does not iterate them separately. + +``` +NUGET_API_KEY=... tsx tools/release/nuget-push.ts --input build/output/nupkg +``` + +## `docker-build.ts` + +Builds one native-arch image via `docker buildx build --load` and +tags it `:-`. The workflow calls it once on +`ubuntu-latest` (`linux/amd64`) and once on `ubuntu-24.04-arm` +(`linux/arm64`); a follow-up `docker-manifest` step merges the two. + +``` +tsx tools/release/docker-build.ts --version 7.0.0-dev.42 --platform linux/amd64 +``` + +## `docker-push.ts` + +Two modes: + +- Per-arch push — `--platform linux/amd64` or `--platform linux/arm64` + pushes the matching per-arch tag. +- Manifest merge — `--manifest` (mutually exclusive with `--platform`) + merges both per-arch tags into a single multi-arch tag + `:` via `docker buildx imagetools create`. + +``` +tsx tools/release/docker-push.ts --version 7.0.0-dev.42 --platform linux/amd64 +tsx tools/release/docker-push.ts --version 7.0.0-dev.42 --manifest +``` + +## `sbom.ts` + +Generates an SPDX SBOM for either the `.nupkg` set (via +`Microsoft.Sbom.DotNetTool`) or a specific Docker image (via +`docker scout sbom`). Writes outputs to `build/output/sbom/`. + +``` +tsx tools/release/sbom.ts --nuget --input build/output/nupkg +tsx tools/release/sbom.ts --docker trakhound/mtconnect-agent:7.0.0-dev.42 +``` + +## `gh-release-create.ts` + +Cuts a GitHub pre-release with the `.nupkg`s + SBOMs attached and +the Docker image reference in the release notes. Always uses +`--prerelease`; stable releases are out of scope for phase 1. + +``` +gh auth login # once, if not already authenticated +tsx tools/release/gh-release-create.ts --version 7.0.0-dev.42 \ + --docker-image trakhound/mtconnect-agent:7.0.0-dev.42 +``` + +## `--dry-run` + +Every script accepts `--dry-run`. Under that flag every subprocess +invocation is logged instead of executed — the shape of the pipeline +can be verified end-to-end on a workstation without publishing. diff --git a/lefthook.yml b/lefthook.yml new file mode 100644 index 000000000..c2d4bf480 --- /dev/null +++ b/lefthook.yml @@ -0,0 +1,37 @@ +# Lefthook — client-side git hook manager. +# +# Two hooks: +# +# commit-msg — validate the commit message against +# `commitlint.config.mjs`. Runs on every `git commit` (except an +# amend that leaves the subject untouched); a violation blocks the +# commit locally so the pre-merge CI gate is the second line of +# defence, not the only one. +# +# pre-commit — run `dotnet format whitespace` on any staged C# or +# csproj file. `stage_fixed: true` re-stages the formatter's +# in-place edits so a `git commit` after a formatter-required diff +# produces a clean-tree commit in a single step. +# +# Enable locally with `lefthook install` (a one-time per-clone step). +# The `.github/workflows/pre-merge.yml` gate replays the commit-msg +# check on every PR head so an un-installed clone cannot bypass it. + +commit-msg: + commands: + commitlint: + # `{1}` is the path to the commit-message file lefthook forwards + # from git — commitlint's `--edit` flag reads it directly. + run: npx commitlint --edit {1} + +pre-commit: + parallel: false + commands: + dotnet-format-whitespace: + # Match every staged .cs / .csproj file. `dotnet format + # whitespace` limits itself to whitespace-only rewrites so the + # diff stays reviewable; the full formatter (`dotnet format`) is + # deferred to CI to avoid a slow pre-commit loop. + glob: '*.{cs,csproj}' + run: dotnet format whitespace MTConnect.NET.sln --include {staged_files} + stage_fixed: true diff --git a/tools/ci/semver-bump.test.ts b/tools/ci/semver-bump.test.ts new file mode 100644 index 000000000..758c85ec4 --- /dev/null +++ b/tools/ci/semver-bump.test.ts @@ -0,0 +1,139 @@ +#!/usr/bin/env -S npx tsx +/** + * Unit tests for the pure functions in `semver-bump.ts`. Run with: + * + * tsx tools/ci/semver-bump.test.ts + * + * Exits 0 when every assertion passes, non-zero otherwise. Not wired + * into a formal test runner (Jest/Vitest) because the module has zero + * runtime deps in these tests and adding a runner just for one file + * paid nothing back. + */ + +import { strict as assert } from 'node:assert'; +import { + aggregateBump, + applyBump, + bumpKindFor, + isDevTag, + isStableCutMarker, +} from './semver-bump.ts'; + +/** Simple test-runner shim — each `test(name, fn)` runs immediately + * and prints pass/fail. A failure throws and terminates the script, + * so the exit code is 1 on the first miss. */ +let passed = 0; +const test = (name: string, fn: () => void): void => { + fn(); + passed += 1; + process.stdout.write(` ok ${name}\n`); +}; + +// ─── bumpKindFor ──────────────────────────────────────────────── +test('bumpKindFor: feat → minor', () => { + assert.equal(bumpKindFor('feat: add xyz', ''), 'minor'); + assert.equal(bumpKindFor('feat(agent): add xyz', ''), 'minor'); +}); + +test('bumpKindFor: fix → patch', () => { + assert.equal(bumpKindFor('fix: correct off-by-one', ''), 'patch'); + assert.equal(bumpKindFor('fix(common): correct', ''), 'patch'); +}); + +test('bumpKindFor: bang → major', () => { + assert.equal(bumpKindFor('feat!: drop v6 API', ''), 'major'); + assert.equal(bumpKindFor('fix(agent)!: rename field', ''), 'major'); +}); + +test('bumpKindFor: BREAKING CHANGE footer → major', () => { + assert.equal( + bumpKindFor('feat: add xyz', 'BREAKING CHANGE: renamed field'), + 'major', + ); + assert.equal( + bumpKindFor('feat: add xyz', 'BREAKING-CHANGE: renamed field'), + 'major', + ); +}); + +test('bumpKindFor: chore/docs/build → patch', () => { + assert.equal(bumpKindFor('chore: bump deps', ''), 'patch'); + assert.equal(bumpKindFor('docs(agent): describe xyz', ''), 'patch'); + assert.equal(bumpKindFor('build(ci): tweak workflow', ''), 'patch'); +}); + +test('bumpKindFor: non-conventional → none', () => { + assert.equal(bumpKindFor('WIP', ''), 'none'); + assert.equal(bumpKindFor('add stuff', ''), 'none'); + assert.equal(bumpKindFor('Merge branch xyz', ''), 'none'); +}); + +// ─── aggregateBump ────────────────────────────────────────────── +test('aggregateBump: major beats everything', () => { + assert.equal(aggregateBump(['patch', 'major', 'minor']), 'major'); +}); + +test('aggregateBump: minor beats patch and none', () => { + assert.equal(aggregateBump(['patch', 'minor', 'none']), 'minor'); +}); + +test('aggregateBump: patch beats none only', () => { + assert.equal(aggregateBump(['none', 'patch', 'none']), 'patch'); +}); + +test('aggregateBump: empty → none', () => { + assert.equal(aggregateBump([]), 'none'); +}); + +// ─── applyBump ────────────────────────────────────────────────── +test('applyBump: major → X+1.0.0', () => { + assert.equal(applyBump('v6.6.0', 'major'), '7.0.0'); + assert.equal(applyBump('6.6.0', 'major'), '7.0.0'); +}); + +test('applyBump: minor → X.Y+1.0', () => { + assert.equal(applyBump('v6.6.0', 'minor'), '6.7.0'); +}); + +test('applyBump: patch → X.Y.Z+1', () => { + assert.equal(applyBump('v6.6.0', 'patch'), '6.6.1'); +}); + +test('applyBump: none → patch bump (avoids version collision)', () => { + assert.equal(applyBump('v6.6.0', 'none'), '6.6.1'); +}); + +test('applyBump: rejects invalid base', () => { + assert.throws(() => applyBump('nope', 'minor'), /not a valid semver/); +}); + +// ─── isStableCutMarker ────────────────────────────────────────── +test('isStableCutMarker: bare marker', () => { + assert.equal(isStableCutMarker('chore(release): publish new stable'), true); +}); + +test('isStableCutMarker: with squash-merge PR suffix', () => { + assert.equal( + isStableCutMarker('chore(release): publish new stable (#123)'), + true, + ); +}); + +test('isStableCutMarker: other chore(release) messages rejected', () => { + assert.equal(isStableCutMarker('chore(release): tweak wording'), false); + assert.equal(isStableCutMarker('chore: publish new stable'), false); +}); + +// ─── isDevTag ─────────────────────────────────────────────────── +test('isDevTag: accepts vX.Y.Z-dev.N', () => { + assert.equal(isDevTag('v6.6.0-dev.42'), true); + assert.equal(isDevTag('v7.0.0-dev.1'), true); +}); + +test('isDevTag: rejects stable + rc tags', () => { + assert.equal(isDevTag('v6.6.0'), false); + assert.equal(isDevTag('v6.6.0-rc.1'), false); + assert.equal(isDevTag('v6.6.0-dev.1.2'), false); +}); + +process.stdout.write(`\n${passed} assertions passed.\n`); diff --git a/tools/ci/semver-bump.ts b/tools/ci/semver-bump.ts new file mode 100644 index 000000000..bc4eaeaf4 --- /dev/null +++ b/tools/ci/semver-bump.ts @@ -0,0 +1,301 @@ +#!/usr/bin/env -S npx tsx +/** + * Compute the next dev pre-release version for a push-to-master build. + * + * The algorithm follows the shape approved on Discussion #175 + * (2026-08-16, Patrick): + * + * 1. Find the most recent tag matching `vX.Y.Z` (stable) — this is + * the base version. If none exists, seed at `v0.0.0`. + * 2. Walk the commits reachable from `HEAD` back to that tag. + * 3. Parse each commit's subject as a Conventional Commit and bump + * the base version accordingly: + * - a `!` in the type/scope, or a `BREAKING CHANGE:` footer, + * bumps the major segment; + * - a `feat(...)` commit bumps the minor segment; + * - any other conventional type bumps the patch segment; + * - non-conventional commits are ignored (they cannot appear + * under a green `pre-merge` gate, but the algorithm stays + * permissive so a partially-migrated history still resolves). + * The largest bump wins — a `BREAKING CHANGE` anywhere in the + * range beats every `feat` and `fix`. + * 4. Count the commits reachable from `HEAD` back to the most + * recent commit that either + * (a) is the stable-cut marker + * `chore(release): publish new stable`, or + * (b) carries an existing `vX.Y.Z-dev.N` tag. + * That count becomes the pre-release counter `N`. A commit + * matching either condition resets `N` to 1 for the next dev + * build. The counter is monotone within one stable-target cycle + * and never reused across cycles. + * 5. Emit `-dev.` on stdout. + * + * When `--github-output` is passed, the script additionally appends + * `version=` to the file referenced by `$GITHUB_OUTPUT` so a + * workflow step can consume it via `${{ steps..outputs.version }}`. + * + * When `--range ..` is passed, the commit range is taken + * verbatim instead of being derived from the most recent stable tag. + * The pre-release counter is then just the number of commits in the + * range plus one (matches the tag-derived case when the range starts + * at the stable cut). Used by the unit-test hook in `test.ts`. + */ + +import { spawnSync } from 'node:child_process'; +import { appendFileSync } from 'node:fs'; +import { parseArgs } from 'node:util'; +import * as semver from 'semver'; + +/** One parsed commit — the subject plus the highest bump kind it demands. */ +export type BumpKind = 'major' | 'minor' | 'patch' | 'none'; + +/** Extract the highest-priority bump kind from a single commit subject + + * optional body. Conventional-commit rules: + * - `!` in the type/scope prefix (`feat!:`, `fix(scope)!:`) → major; + * - a `BREAKING CHANGE:` or `BREAKING-CHANGE:` footer/line → major; + * - subject starts with `feat` (case-insensitive) → minor; + * - subject starts with any other type (`fix|chore|docs|style|refactor| + * perf|test|build|ci|revert`) → patch; + * - anything else → none. */ +export const bumpKindFor = (subject: string, body: string): BumpKind => { + const s = subject.trim(); + // A `!` before the colon marks a breaking change per the Conventional + // Commits spec — accepts `feat!:`, `fix(scope)!:`, `feat(scope)!:`. + const bangMatch = /^([a-zA-Z]+)(\([^)]*\))?!:/.exec(s); + if (bangMatch) return 'major'; + if (/(^|\n)BREAKING[ -]CHANGE:/i.test(body)) return 'major'; + + const typeMatch = /^([a-zA-Z]+)(\([^)]*\))?:/.exec(s); + if (!typeMatch) return 'none'; + const type = typeMatch[1]!.toLowerCase(); + if (type === 'feat') return 'minor'; + const known = new Set([ + 'fix', + 'chore', + 'docs', + 'style', + 'refactor', + 'perf', + 'test', + 'build', + 'ci', + 'revert', + ]); + return known.has(type) ? 'patch' : 'none'; +}; + +/** Pick the highest bump kind over a list of commits — major > minor > patch > none. */ +export const aggregateBump = (kinds: BumpKind[]): BumpKind => { + if (kinds.includes('major')) return 'major'; + if (kinds.includes('minor')) return 'minor'; + if (kinds.includes('patch')) return 'patch'; + return 'none'; +}; + +/** Apply a bump kind to a base version. `none` still bumps patch so a + * chore-only range still produces a distinct pre-release version — the + * dev counter would collide otherwise. */ +export const applyBump = (base: string, kind: BumpKind): string => { + const clean = base.startsWith('v') ? base.slice(1) : base; + const parsed = semver.parse(clean); + if (!parsed) { + throw new Error(`semver-bump: base "${base}" is not a valid semver`); + } + switch (kind) { + case 'major': + return semver.inc(clean, 'major')!; + case 'minor': + return semver.inc(clean, 'minor')!; + case 'patch': + case 'none': + return semver.inc(clean, 'patch')!; + } +}; + +/** Test whether a commit subject is the stable-cut marker. Accepts the + * exact string in either the subject line or as a squash-merge PR title + * suffix ("...(#123)"). Case-sensitive on the marker itself so a random + * `chore(release): tweak wording` does not reset the counter. */ +export const isStableCutMarker = (subject: string): boolean => { + return /^chore\(release\): publish new stable( \(#\d+\))?$/.test(subject.trim()); +}; + +/** Test whether a tag looks like an existing dev pre-release tag + * (`vX.Y.Z-dev.N`). Used to detect the last dev-cut boundary when the + * most recent commit is neither the stable-cut marker nor tagged as + * stable. */ +export const isDevTag = (tag: string): boolean => { + return /^v\d+\.\d+\.\d+-dev\.\d+$/.test(tag); +}; + +/** Run `git` with args and return stdout. Throws on non-zero exit so the + * workflow fails loudly rather than emitting a bogus version. */ +const git = (args: string[]): string => { + const r = spawnSync('git', args, { encoding: 'utf8' }); + if (r.status !== 0) { + throw new Error( + `git ${args.join(' ')} failed (exit ${r.status}): ${r.stderr.trim() || r.stdout.trim()}`, + ); + } + return r.stdout; +}; + +/** Return the most recent stable tag reachable from HEAD, or `v0.0.0` + * when the repo has never been tagged with a bare `vX.Y.Z`. Uses + * `git describe` to walk parent history. The match/exclude pair is + * deliberately conservative: + * + * - `--match 'v[0-9]*.[0-9]*.[0-9]*'` — start narrow, at least + * three dot-separated numeric-lead segments prefixed with `v`; + * - `--exclude 'v*-*'` — reject anything with a hyphen suffix + * (`-dev.N`, `-rc.1`, `-beta-agents`, `-prerelease`, …). Only a + * pure numeric `vX.Y.Z` tag survives. + * + * Falls back to `v0.0.0` on the sentinel "no names found" error + * rather than propagating it. */ +export const lastStableTag = (): string => { + const r = spawnSync( + 'git', + [ + 'describe', + '--tags', + '--abbrev=0', + '--match', + 'v[0-9]*.[0-9]*.[0-9]*', + '--exclude', + 'v*-*', + ], + { encoding: 'utf8' }, + ); + if (r.status !== 0) { + if (/No names found/i.test(r.stderr)) return 'v0.0.0'; + throw new Error(`git describe failed: ${r.stderr.trim()}`); + } + return r.stdout.trim(); +}; + +/** Return the list of commits in `..HEAD` as an array of + * {sha, subject, body}. Uses `%x1e` (record separator) between commits + * and `%x1f` (unit separator) between fields to survive bodies with + * arbitrary whitespace and quoted content. */ +export const commitsInRange = ( + from: string, + to: string = 'HEAD', +): Array<{ sha: string; subject: string; body: string }> => { + // Empty output when the range is empty (from == to) — handle gracefully. + const raw = git(['log', `${from}..${to}`, '--pretty=format:%H%x1f%s%x1f%b%x1e']); + if (!raw.trim()) return []; + return raw + .split('\x1e') + .map((rec) => rec.trim()) + .filter((rec) => rec.length > 0) + .map((rec) => { + const [sha, subject, body] = rec.split('\x1f'); + return { sha: sha ?? '', subject: subject ?? '', body: body ?? '' }; + }); +}; + +/** Count how many commits reachable from HEAD (walking parents) come + * before we hit either the stable-cut marker or a `vX.Y.Z-dev.N` + * tag. Returns the count of commits that are still on the "current" + * dev cycle, i.e. the `N` for the next `-dev.`. */ +export const countCommitsSinceLastDevBoundary = (): number => { + // Fastest path: if HEAD or an ancestor is tagged with a stable version, + // the count is (commits since that tag) + 1. + const stable = lastStableTag(); + const commits = commitsInRange(stable); + + // Now scan those commits for the stable-cut marker or a dev tag on + // the commit itself. If we find one, N = (commits between it and + // HEAD) + 1. + for (let i = 0; i < commits.length; i++) { + const c = commits[i]!; + if (isStableCutMarker(c.subject)) { + return i + 1; + } + // Any dev tag on this commit resets the counter to i + 1. + const tagsRaw = spawnSync( + 'git', + ['tag', '--points-at', c.sha, '--list', 'v*-dev.*'], + { encoding: 'utf8' }, + ); + if (tagsRaw.status === 0) { + const tags = tagsRaw.stdout.split('\n').map((t) => t.trim()).filter((t) => isDevTag(t)); + if (tags.length > 0) return i + 1; + } + } + // No stable-cut marker and no dev-tag boundary found — the count + // includes every commit since the last stable release. + return Math.max(1, commits.length); +}; + +/** + * Top-level entry: compute the next dev pre-release version and print it. + * Accepts `--github-output` to also append to the GH Actions output file + * and `--range ..` to short-circuit the tag lookup (used by tests). + */ +export const main = (argv: string[]): void => { + const { values } = parseArgs({ + args: argv, + options: { + 'github-output': { type: 'boolean', default: false }, + range: { type: 'string' }, + }, + }); + + let base: string; + let commits: Array<{ sha: string; subject: string; body: string }>; + let counter: number; + + if (values.range) { + const [from, toRaw] = values.range.split('..'); + const to = toRaw && toRaw.length > 0 ? toRaw : 'HEAD'; + if (!from) { + throw new Error(`--range must be of the form .., got "${values.range}"`); + } + // Extract the base version from the `from` ref when it looks like a + // stable tag; otherwise fall back to the most recent stable tag. + base = /^v\d+\.\d+\.\d+$/.test(from) ? from : lastStableTag(); + commits = commitsInRange(from, to); + counter = Math.max(1, commits.length); + } else { + base = lastStableTag(); + commits = commitsInRange(base); + counter = countCommitsSinceLastDevBoundary(); + } + + const kind = aggregateBump(commits.map((c) => bumpKindFor(c.subject, c.body))); + const bumped = applyBump(base, kind); + const version = `${bumped}-dev.${counter}`; + + // Print to stdout in a shape a workflow step can capture directly. + process.stdout.write(`${version}\n`); + + if (values['github-output'] && process.env.GITHUB_OUTPUT) { + appendFileSync(process.env.GITHUB_OUTPUT, `version=${version}\n`); + appendFileSync(process.env.GITHUB_OUTPUT, `base=${base}\n`); + appendFileSync(process.env.GITHUB_OUTPUT, `bump=${kind}\n`); + appendFileSync(process.env.GITHUB_OUTPUT, `counter=${counter}\n`); + } +}; + +// ESM-safe "run when invoked directly" — no `require.main` in ES modules. +// Compares the script URL to the process entrypoint; skips when imported. +const invokedDirectly = (() => { + const entry = process.argv[1]; + if (!entry) return false; + try { + return new URL(`file://${entry}`).href === import.meta.url; + } catch { + return false; + } +})(); + +if (invokedDirectly) { + try { + main(process.argv.slice(2)); + } catch (err) { + process.stderr.write(`semver-bump: ${(err as Error).message}\n`); + process.exit(1); + } +} diff --git a/tools/dev/README.md b/tools/dev/README.md new file mode 100644 index 000000000..208b267b1 --- /dev/null +++ b/tools/dev/README.md @@ -0,0 +1,16 @@ +# `tools/dev/` — local development-loop helpers + +This directory will hold repo-side scripts that speed up the local +inner loop — spin up a demo agent against a fake adapter, tail agent +logs while a change is being iterated, regenerate one narrow slice of +the docs without paying the full `npm run regen` wall-clock, and +similar. + +Empty on purpose. The first helper will be added in a follow-up PR +once the release pipeline in `tools/release/` is stable and the +inner-loop pain points are cleaner to prioritise. + +For the currently-shipped inner-loop scripts (`tools/dotnet.sh`, +`tools/test.sh`) see the sibling docs under `docs/cli/dotnet-sh` and +`docs/cli/test-sh` — those pre-date this reorganisation and stay at +`tools/` root so their existing CI + doc references are undisturbed. diff --git a/tools/docs/README.md b/tools/docs/README.md new file mode 100644 index 000000000..84560b160 --- /dev/null +++ b/tools/docs/README.md @@ -0,0 +1,12 @@ +# `tools/docs/` — documentation-generation helpers + +This directory will hold repo-side scripts that produce inputs the +VitePress site consumes — spec cross-references, wire-format sample +regeneration, per-version compliance matrix rebuilds, and similar. + +Empty on purpose. The existing generators live under +`docs/scripts/generate-api-ref.sh` and +`docs/scripts/generate-reference.sh` (invoked by `docs/`'s npm +`predev` / `prebuild` hooks) and stay there for now to keep the +docs-site self-contained. Follow-up PRs will migrate cross-cutting +generators to this directory as the release pipeline lands. diff --git a/tools/package-lock.json b/tools/package-lock.json new file mode 100644 index 000000000..e184dd4fe --- /dev/null +++ b/tools/package-lock.json @@ -0,0 +1,805 @@ +{ + "name": "@mtconnect-net/tools", + "version": "0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "@mtconnect-net/tools", + "version": "0.0.0", + "dependencies": { + "@octokit/rest": "^21.0.0", + "semver": "^7.6.3" + }, + "devDependencies": { + "@types/node": "^20.14.0", + "@types/semver": "^7.5.8", + "tsx": "^4.19.0", + "typescript": "^5.5.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz", + "integrity": "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.2.tgz", + "integrity": "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.2.tgz", + "integrity": "sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.2.tgz", + "integrity": "sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.2.tgz", + "integrity": "sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.2.tgz", + "integrity": "sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.2.tgz", + "integrity": "sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.2.tgz", + "integrity": "sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.2.tgz", + "integrity": "sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.2.tgz", + "integrity": "sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.2.tgz", + "integrity": "sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.2.tgz", + "integrity": "sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.2.tgz", + "integrity": "sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.2.tgz", + "integrity": "sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.2.tgz", + "integrity": "sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.2.tgz", + "integrity": "sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.2.tgz", + "integrity": "sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.2.tgz", + "integrity": "sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.2.tgz", + "integrity": "sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.2.tgz", + "integrity": "sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.2.tgz", + "integrity": "sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.2.tgz", + "integrity": "sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.2.tgz", + "integrity": "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.2.tgz", + "integrity": "sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.2.tgz", + "integrity": "sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.2.tgz", + "integrity": "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@octokit/auth-token": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/@octokit/auth-token/-/auth-token-5.1.2.tgz", + "integrity": "sha512-JcQDsBdg49Yky2w2ld20IHAlwr8d/d8N6NiOXbtuoPCqzbsiJgF633mVUw3x4mo0H5ypataQIX7SFu3yy44Mpw==", + "license": "MIT", + "engines": { + "node": ">= 18" + } + }, + "node_modules/@octokit/core": { + "version": "6.1.6", + "resolved": "https://registry.npmjs.org/@octokit/core/-/core-6.1.6.tgz", + "integrity": "sha512-kIU8SLQkYWGp3pVKiYzA5OSaNF5EE03P/R8zEmmrG6XwOg5oBjXyQVVIauQ0dgau4zYhpZEhJrvIYt6oM+zZZA==", + "license": "MIT", + "dependencies": { + "@octokit/auth-token": "^5.0.0", + "@octokit/graphql": "^8.2.2", + "@octokit/request": "^9.2.3", + "@octokit/request-error": "^6.1.8", + "@octokit/types": "^14.0.0", + "before-after-hook": "^3.0.2", + "universal-user-agent": "^7.0.0" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/@octokit/endpoint": { + "version": "10.1.4", + "resolved": "https://registry.npmjs.org/@octokit/endpoint/-/endpoint-10.1.4.tgz", + "integrity": "sha512-OlYOlZIsfEVZm5HCSR8aSg02T2lbUWOsCQoPKfTXJwDzcHQBrVBGdGXb89dv2Kw2ToZaRtudp8O3ZIYoaOjKlA==", + "license": "MIT", + "dependencies": { + "@octokit/types": "^14.0.0", + "universal-user-agent": "^7.0.2" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/@octokit/graphql": { + "version": "8.2.2", + "resolved": "https://registry.npmjs.org/@octokit/graphql/-/graphql-8.2.2.tgz", + "integrity": "sha512-Yi8hcoqsrXGdt0yObxbebHXFOiUA+2v3n53epuOg1QUgOB6c4XzvisBNVXJSl8RYA5KrDuSL2yq9Qmqe5N0ryA==", + "license": "MIT", + "dependencies": { + "@octokit/request": "^9.2.3", + "@octokit/types": "^14.0.0", + "universal-user-agent": "^7.0.0" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/@octokit/openapi-types": { + "version": "25.1.0", + "resolved": "https://registry.npmjs.org/@octokit/openapi-types/-/openapi-types-25.1.0.tgz", + "integrity": "sha512-idsIggNXUKkk0+BExUn1dQ92sfysJrje03Q0bv0e+KPLrvyqZF8MnBpFz8UNfYDwB3Ie7Z0TByjWfzxt7vseaA==", + "license": "MIT" + }, + "node_modules/@octokit/plugin-paginate-rest": { + "version": "11.6.0", + "resolved": "https://registry.npmjs.org/@octokit/plugin-paginate-rest/-/plugin-paginate-rest-11.6.0.tgz", + "integrity": "sha512-n5KPteiF7pWKgBIBJSk8qzoZWcUkza2O6A0za97pMGVrGfPdltxrfmfF5GucHYvHGZD8BdaZmmHGz5cX/3gdpw==", + "license": "MIT", + "dependencies": { + "@octokit/types": "^13.10.0" + }, + "engines": { + "node": ">= 18" + }, + "peerDependencies": { + "@octokit/core": ">=6" + } + }, + "node_modules/@octokit/plugin-paginate-rest/node_modules/@octokit/openapi-types": { + "version": "24.2.0", + "resolved": "https://registry.npmjs.org/@octokit/openapi-types/-/openapi-types-24.2.0.tgz", + "integrity": "sha512-9sIH3nSUttelJSXUrmGzl7QUBFul0/mB8HRYl3fOlgHbIWG+WnYDXU3v/2zMtAvuzZ/ed00Ei6on975FhBfzrg==", + "license": "MIT" + }, + "node_modules/@octokit/plugin-paginate-rest/node_modules/@octokit/types": { + "version": "13.10.0", + "resolved": "https://registry.npmjs.org/@octokit/types/-/types-13.10.0.tgz", + "integrity": "sha512-ifLaO34EbbPj0Xgro4G5lP5asESjwHracYJvVaPIyXMuiuXLlhic3S47cBdTb+jfODkTE5YtGCLt3Ay3+J97sA==", + "license": "MIT", + "dependencies": { + "@octokit/openapi-types": "^24.2.0" + } + }, + "node_modules/@octokit/plugin-request-log": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/@octokit/plugin-request-log/-/plugin-request-log-5.3.1.tgz", + "integrity": "sha512-n/lNeCtq+9ofhC15xzmJCNKP2BWTv8Ih2TTy+jatNCCq/gQP/V7rK3fjIfuz0pDWDALO/o/4QY4hyOF6TQQFUw==", + "license": "MIT", + "engines": { + "node": ">= 18" + }, + "peerDependencies": { + "@octokit/core": ">=6" + } + }, + "node_modules/@octokit/plugin-rest-endpoint-methods": { + "version": "13.5.0", + "resolved": "https://registry.npmjs.org/@octokit/plugin-rest-endpoint-methods/-/plugin-rest-endpoint-methods-13.5.0.tgz", + "integrity": "sha512-9Pas60Iv9ejO3WlAX3maE1+38c5nqbJXV5GrncEfkndIpZrJ/WPMRd2xYDcPPEt5yzpxcjw9fWNoPhsSGzqKqw==", + "license": "MIT", + "dependencies": { + "@octokit/types": "^13.10.0" + }, + "engines": { + "node": ">= 18" + }, + "peerDependencies": { + "@octokit/core": ">=6" + } + }, + "node_modules/@octokit/plugin-rest-endpoint-methods/node_modules/@octokit/openapi-types": { + "version": "24.2.0", + "resolved": "https://registry.npmjs.org/@octokit/openapi-types/-/openapi-types-24.2.0.tgz", + "integrity": "sha512-9sIH3nSUttelJSXUrmGzl7QUBFul0/mB8HRYl3fOlgHbIWG+WnYDXU3v/2zMtAvuzZ/ed00Ei6on975FhBfzrg==", + "license": "MIT" + }, + "node_modules/@octokit/plugin-rest-endpoint-methods/node_modules/@octokit/types": { + "version": "13.10.0", + "resolved": "https://registry.npmjs.org/@octokit/types/-/types-13.10.0.tgz", + "integrity": "sha512-ifLaO34EbbPj0Xgro4G5lP5asESjwHracYJvVaPIyXMuiuXLlhic3S47cBdTb+jfODkTE5YtGCLt3Ay3+J97sA==", + "license": "MIT", + "dependencies": { + "@octokit/openapi-types": "^24.2.0" + } + }, + "node_modules/@octokit/request": { + "version": "9.2.4", + "resolved": "https://registry.npmjs.org/@octokit/request/-/request-9.2.4.tgz", + "integrity": "sha512-q8ybdytBmxa6KogWlNa818r0k1wlqzNC+yNkcQDECHvQo8Vmstrg18JwqJHdJdUiHD2sjlwBgSm9kHkOKe2iyA==", + "license": "MIT", + "dependencies": { + "@octokit/endpoint": "^10.1.4", + "@octokit/request-error": "^6.1.8", + "@octokit/types": "^14.0.0", + "fast-content-type-parse": "^2.0.0", + "universal-user-agent": "^7.0.2" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/@octokit/request-error": { + "version": "6.1.8", + "resolved": "https://registry.npmjs.org/@octokit/request-error/-/request-error-6.1.8.tgz", + "integrity": "sha512-WEi/R0Jmq+IJKydWlKDmryPcmdYSVjL3ekaiEL1L9eo1sUnqMJ+grqmC9cjk7CA7+b2/T397tO5d8YLOH3qYpQ==", + "license": "MIT", + "dependencies": { + "@octokit/types": "^14.0.0" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/@octokit/rest": { + "version": "21.1.1", + "resolved": "https://registry.npmjs.org/@octokit/rest/-/rest-21.1.1.tgz", + "integrity": "sha512-sTQV7va0IUVZcntzy1q3QqPm/r8rWtDCqpRAmb8eXXnKkjoQEtFe3Nt5GTVsHft+R6jJoHeSiVLcgcvhtue/rg==", + "license": "MIT", + "dependencies": { + "@octokit/core": "^6.1.4", + "@octokit/plugin-paginate-rest": "^11.4.2", + "@octokit/plugin-request-log": "^5.3.1", + "@octokit/plugin-rest-endpoint-methods": "^13.3.0" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/@octokit/types": { + "version": "14.1.0", + "resolved": "https://registry.npmjs.org/@octokit/types/-/types-14.1.0.tgz", + "integrity": "sha512-1y6DgTy8Jomcpu33N+p5w58l6xyt55Ar2I91RPiIA0xCJBXyUAhXCcmZaDWSANiha7R9a6qJJ2CRomGPZ6f46g==", + "license": "MIT", + "dependencies": { + "@octokit/openapi-types": "^25.1.0" + } + }, + "node_modules/@types/node": { + "version": "20.19.43", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.43.tgz", + "integrity": "sha512-6oYBAi5ikg4Pl+kGsoYtawUMBT2zZMCvPNF7pVLnHZfd1zf38DRiWn/gT01RYCdUqkv7Fhr+C9ot4/tb+2sVvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@types/semver": { + "version": "7.8.0", + "resolved": "https://registry.npmjs.org/@types/semver/-/semver-7.8.0.tgz", + "integrity": "sha512-1mAINjtQCXXeLkJ9ehXkwOcBpqtLxiVtKhpUf83DdRNdQKV0iXZpaHYqRr7nj+wvxuJzoAmAwXI+sCNMv1CzLQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/before-after-hook": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/before-after-hook/-/before-after-hook-3.0.2.tgz", + "integrity": "sha512-Nik3Sc0ncrMK4UUdXQmAnRtzmNQTAAXmXIopizwZ1W1t8QmfJj+zL4OA2I7XPTPW5z5TDqv4hRo/JzouDJnX3A==", + "license": "Apache-2.0" + }, + "node_modules/esbuild": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz", + "integrity": "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.2", + "@esbuild/android-arm": "0.28.2", + "@esbuild/android-arm64": "0.28.2", + "@esbuild/android-x64": "0.28.2", + "@esbuild/darwin-arm64": "0.28.2", + "@esbuild/darwin-x64": "0.28.2", + "@esbuild/freebsd-arm64": "0.28.2", + "@esbuild/freebsd-x64": "0.28.2", + "@esbuild/linux-arm": "0.28.2", + "@esbuild/linux-arm64": "0.28.2", + "@esbuild/linux-ia32": "0.28.2", + "@esbuild/linux-loong64": "0.28.2", + "@esbuild/linux-mips64el": "0.28.2", + "@esbuild/linux-ppc64": "0.28.2", + "@esbuild/linux-riscv64": "0.28.2", + "@esbuild/linux-s390x": "0.28.2", + "@esbuild/linux-x64": "0.28.2", + "@esbuild/netbsd-arm64": "0.28.2", + "@esbuild/netbsd-x64": "0.28.2", + "@esbuild/openbsd-arm64": "0.28.2", + "@esbuild/openbsd-x64": "0.28.2", + "@esbuild/openharmony-arm64": "0.28.2", + "@esbuild/sunos-x64": "0.28.2", + "@esbuild/win32-arm64": "0.28.2", + "@esbuild/win32-ia32": "0.28.2", + "@esbuild/win32-x64": "0.28.2" + } + }, + "node_modules/fast-content-type-parse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/fast-content-type-parse/-/fast-content-type-parse-2.0.1.tgz", + "integrity": "sha512-nGqtvLrj5w0naR6tDPfB4cUmYCqouzyQiz6C5y/LtcDllJdrcc6WaWW6iXyIIOErTa/XRybj28aasdn4LkVk6Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/tsx": { + "version": "4.23.12", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.23.12.tgz", + "integrity": "sha512-FDf4L4sYzKtzWYhU/Xm0AQFdTjdIxNo9ElTf2mxXM6k8YMHXzYUe4yODVaXP4V9uMFbVg8c0qyBccK2OOxb45Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.28.0" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/universal-user-agent": { + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/universal-user-agent/-/universal-user-agent-7.0.3.tgz", + "integrity": "sha512-TmnEAEAsBJVZM/AADELsK76llnwcf9vMKuPz8JflO1frO8Lchitr0fNaN9d+Ap0BjKtqWqd/J17qeDnXh8CL2A==", + "license": "ISC" + } + } +} diff --git a/tools/package.json b/tools/package.json new file mode 100644 index 000000000..603bb7be6 --- /dev/null +++ b/tools/package.json @@ -0,0 +1,23 @@ +{ + "name": "@mtconnect-net/tools", + "version": "0.0.0", + "private": true, + "description": "Repo-side automation scripts run by CI workflows. Not published; not consumed by application code.", + "type": "module", + "engines": { + "node": ">=20" + }, + "scripts": { + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@octokit/rest": "^21.0.0", + "semver": "^7.6.3" + }, + "devDependencies": { + "@types/node": "^20.14.0", + "@types/semver": "^7.5.8", + "tsx": "^4.19.0", + "typescript": "^5.5.0" + } +} diff --git a/tools/release/docker-build.ts b/tools/release/docker-build.ts new file mode 100644 index 000000000..0e2a98a4f --- /dev/null +++ b/tools/release/docker-build.ts @@ -0,0 +1,128 @@ +#!/usr/bin/env -S npx tsx +/** + * Build one native-arch Docker image for the agent (linux/amd64 or + * linux/arm64). The workflow calls this script twice — once per arch, + * on a matching runner — and then `docker-push.ts` on each arch, and + * finally the workflow's `docker-manifest` step merges the two per-arch + * tags into a single multi-arch tag. + * + * The rationale for native builds (vs a single QEMU-emulated buildx + * matrix) is wall-clock: on the current `ubuntu-24.04-arm` and + * `ubuntu-latest` runners, native `dotnet publish` for the arm64 leg + * runs in ~3 min against ~18 min under QEMU emulation. + * + * Usage: + * tsx tools/release/docker-build.ts --version --platform + * [--image ] [--dry-run] + * + * The image name defaults to `trakhound/mtconnect-agent` and the tag + * becomes `:-` where the suffix is + * `amd64` / `arm64`. The multi-arch merge in `docker-manifest` then + * points `:` at both. + */ + +import { resolve } from 'node:path'; +import { parseArgs } from 'node:util'; +import { parseDryRun, run } from './shell.ts'; + +/** Repo root — used to resolve the Dockerfile and build context. */ +const repoRoot = resolve(new URL('../../', import.meta.url).pathname); + +/** Platform strings this script accepts. */ +type Platform = 'linux/amd64' | 'linux/arm64'; + +/** Map platform to the arch-suffix used in the per-arch tag. */ +const archSuffixFor = (p: Platform): 'amd64' | 'arm64' => { + return p === 'linux/amd64' ? 'amd64' : 'arm64'; +}; + +/** CLI options. */ +type Options = { + version: string; + platform: Platform; + image: string; + dryRun: boolean; +}; + +/** Parse argv into strongly-typed `Options`. */ +const parseOptions = (argv: string[]): Options => { + const { dryRun, rest } = parseDryRun(argv); + const { values } = parseArgs({ + args: rest, + options: { + version: { type: 'string' }, + platform: { type: 'string' }, + image: { type: 'string' }, + }, + }); + if (!values.version) { + throw new Error('--version is required'); + } + if (values.platform !== 'linux/amd64' && values.platform !== 'linux/arm64') { + throw new Error( + `--platform must be linux/amd64 or linux/arm64, got "${values.platform ?? ''}"`, + ); + } + return { + version: values.version, + platform: values.platform, + image: values.image ?? 'trakhound/mtconnect-agent', + dryRun, + }; +}; + +/** Build the image with `docker buildx build --load`, tagging it with + * the per-arch suffix. The manifest merge is `docker-push.ts`'s job. */ +export const main = async (argv: string[]): Promise => { + const opts = parseOptions(argv); + + const arch = archSuffixFor(opts.platform); + const tag = `${opts.image}:${opts.version}-${arch}`; + const dockerfile = resolve( + repoRoot, + 'build', + 'MTConnect.NET.Builder', + 'Parts', + 'agent', + 'docker', + 'Dockerfile', + ); + + const args = [ + 'buildx', + 'build', + '--platform', + opts.platform, + '--file', + dockerfile, + '--tag', + tag, + '--load', + // Emit provenance + SBOM metadata inside the OCI image; the + // downstream `sbom.ts` step reads them back out via + // `docker buildx imagetools inspect`. + '--provenance', + 'mode=max', + '--sbom', + 'true', + repoRoot, + ]; + await run('docker', args, { dryRun: opts.dryRun, cwd: repoRoot }); +}; + +const invokedDirectly = (() => { + const entry = process.argv[1]; + if (!entry) return false; + try { + return new URL(`file://${entry}`).href === import.meta.url; + } catch { + return false; + } +})(); + +if (invokedDirectly) { + main(process.argv.slice(2)).catch((err) => { + process.stderr.write(`docker-build: ${(err as Error).message}\n`); + process.exit(1); + }); +} diff --git a/tools/release/docker-push.ts b/tools/release/docker-push.ts new file mode 100644 index 000000000..8a7a253a2 --- /dev/null +++ b/tools/release/docker-push.ts @@ -0,0 +1,119 @@ +#!/usr/bin/env -S npx tsx +/** + * Push one per-arch Docker tag to Docker Hub. Called from the release + * workflow's `docker-amd64` and `docker-arm64` jobs after + * `docker-build.ts` has produced the tagged image on the runner. + * + * Optionally merges the per-arch tags into a single multi-arch tag + * via `docker buildx imagetools create` when `--manifest` is passed + * without `--platform`; that mode is what the `docker-manifest` job + * runs after both per-arch pushes complete. + * + * Usage (per-arch push): + * tsx tools/release/docker-push.ts --version + * --platform + * [--image ] [--dry-run] + * + * Usage (manifest merge): + * tsx tools/release/docker-push.ts --version --manifest + * [--image ] [--dry-run] + * + * Docker Hub credentials come from `DOCKERHUB_USERNAME` + + * `DOCKERHUB_TOKEN` — a `docker login` in the workflow step precedes + * this script (via `docker/login-action`); the script itself does not + * touch the credentials. + */ + +import { parseArgs } from 'node:util'; +import { parseDryRun, run } from './shell.ts'; + +/** Platform strings this script accepts. */ +type Platform = 'linux/amd64' | 'linux/arm64'; + +/** Map platform to the arch-suffix used in the per-arch tag. */ +const archSuffixFor = (p: Platform): 'amd64' | 'arm64' => { + return p === 'linux/amd64' ? 'amd64' : 'arm64'; +}; + +/** CLI options. */ +type Options = { + version: string; + platform: Platform | undefined; + manifest: boolean; + image: string; + dryRun: boolean; +}; + +/** Parse argv into strongly-typed `Options`. */ +const parseOptions = (argv: string[]): Options => { + const { dryRun, rest } = parseDryRun(argv); + const { values } = parseArgs({ + args: rest, + options: { + version: { type: 'string' }, + platform: { type: 'string' }, + manifest: { type: 'boolean', default: false }, + image: { type: 'string' }, + }, + }); + if (!values.version) { + throw new Error('--version is required'); + } + const platform = values.platform; + if (platform && platform !== 'linux/amd64' && platform !== 'linux/arm64') { + throw new Error( + `--platform must be linux/amd64 or linux/arm64, got "${platform}"`, + ); + } + if (!platform && !values.manifest) { + throw new Error('Either --platform or --manifest must be provided'); + } + if (platform && values.manifest) { + throw new Error('--platform and --manifest are mutually exclusive'); + } + return { + version: values.version, + platform: platform as Platform | undefined, + manifest: values.manifest, + image: values.image ?? 'trakhound/mtconnect-agent', + dryRun, + }; +}; + +/** Push the per-arch tag, or merge both into a single multi-arch tag. */ +export const main = async (argv: string[]): Promise => { + const opts = parseOptions(argv); + + if (opts.manifest) { + // Multi-arch merge — reference both per-arch tags and publish a + // single tag that clients resolve to the right arch automatically. + const target = `${opts.image}:${opts.version}`; + const amd64 = `${opts.image}:${opts.version}-amd64`; + const arm64 = `${opts.image}:${opts.version}-arm64`; + await run('docker', ['buildx', 'imagetools', 'create', '--tag', target, amd64, arm64], { + dryRun: opts.dryRun, + }); + return; + } + + const arch = archSuffixFor(opts.platform!); + const tag = `${opts.image}:${opts.version}-${arch}`; + await run('docker', ['push', tag], { dryRun: opts.dryRun }); +}; + +const invokedDirectly = (() => { + const entry = process.argv[1]; + if (!entry) return false; + try { + return new URL(`file://${entry}`).href === import.meta.url; + } catch { + return false; + } +})(); + +if (invokedDirectly) { + main(process.argv.slice(2)).catch((err) => { + process.stderr.write(`docker-push: ${(err as Error).message}\n`); + process.exit(1); + }); +} diff --git a/tools/release/gh-release-create.ts b/tools/release/gh-release-create.ts new file mode 100644 index 000000000..db366d8ae --- /dev/null +++ b/tools/release/gh-release-create.ts @@ -0,0 +1,178 @@ +#!/usr/bin/env -S npx tsx +/** + * Create a GitHub pre-release for a `-dev.` cut and attach + * the SBOMs + .nupkg files that the earlier pipeline steps produced. + * + * Uses the `gh` CLI rather than `@octokit/rest` for the release + * creation itself — `gh release create` handles the multipart asset + * upload semantics without any of the retry / MIME plumbing an + * Octokit-side implementation would need. Octokit remains available + * to `tools/` scripts that need the fine-grained REST surface (see + * `tools/package.json`), just not this one. + * + * Usage: + * tsx tools/release/gh-release-create.ts --version + * [--repo ] + * [--assets ...] + * [--docker-image ] + * [--dry-run] + * + * The release is always created as `--prerelease` (Phase 1 automates + * only the dev pre-release cadence; stable releases stay under the + * existing MTConnect.NET.Builder flow until a follow-up wires them + * in). Release notes list every attached asset plus the docker image + * reference so consumers have a single machine-readable manifest of + * the cut. + */ + +import { readdirSync, existsSync, writeFileSync, mkdirSync } from 'node:fs'; +import { resolve } from 'node:path'; +import { parseArgs } from 'node:util'; +import { parseDryRun, run } from './shell.ts'; + +/** Repo root — used for default asset directories. */ +const repoRoot = resolve(new URL('../../', import.meta.url).pathname); + +/** CLI options. */ +type Options = { + version: string; + repo: string; + assetDirs: string[]; + dockerImage: string | undefined; + dryRun: boolean; +}; + +/** Parse argv into strongly-typed `Options`. Multiple `--assets` flags + * accumulate into a list; missing dirs are skipped with a warning + * (a workflow may pass both `nupkg/` and `sbom/` even when only one + * step ran). */ +const parseOptions = (argv: string[]): Options => { + const { dryRun, rest } = parseDryRun(argv); + const { values } = parseArgs({ + args: rest, + options: { + version: { type: 'string' }, + repo: { type: 'string' }, + assets: { type: 'string', multiple: true }, + 'docker-image': { type: 'string' }, + }, + }); + if (!values.version) { + throw new Error('--version is required'); + } + const assetDirs = values.assets ?? [ + resolve(repoRoot, 'build', 'output', 'nupkg'), + resolve(repoRoot, 'build', 'output', 'sbom'), + ]; + return { + version: values.version, + repo: values.repo ?? 'TrakHound/MTConnect.NET', + assetDirs, + dockerImage: values['docker-image'], + dryRun, + }; +}; + +/** Enumerate assets across the requested directories, returning + * absolute paths. Recurses one level so `sbom/*.spdx.json` and + * `nupkg/*.nupkg` are both picked up without special-casing. */ +const collectAssets = (dirs: string[]): string[] => { + const files: string[] = []; + for (const dir of dirs) { + if (!existsSync(dir)) { + process.stderr.write(`[gh-release-create] skipping missing dir: ${dir}\n`); + continue; + } + for (const name of readdirSync(dir)) { + const path = resolve(dir, name); + if (name.startsWith('.')) continue; + files.push(path); + } + } + return files; +}; + +/** Build the release-notes body — a short header plus a + * human-readable manifest of every attached asset and the docker + * image reference. Kept plain-markdown so the GitHub release page + * renders it without extension conversions. */ +const renderReleaseNotes = ( + version: string, + assets: string[], + dockerImage: string | undefined, +): string => { + const lines: string[] = []; + lines.push(`# MTConnect.NET ${version}`); + lines.push(''); + lines.push( + 'Automated dev pre-release cut by the CI release pipeline. Not intended for production use — the stable-release cadence still runs through the manual `MTConnect.NET.Builder` flow.', + ); + lines.push(''); + lines.push('## Assets'); + lines.push(''); + if (assets.length === 0) { + lines.push('_No assets attached._'); + } else { + for (const a of assets) { + lines.push(`- \`${a.split('/').pop()}\``); + } + } + lines.push(''); + if (dockerImage) { + lines.push('## Docker image'); + lines.push(''); + lines.push('```'); + lines.push(`docker pull ${dockerImage}`); + lines.push('```'); + lines.push(''); + } + return lines.join('\n'); +}; + +/** Entry point — write the notes file, then invoke `gh release + * create`. */ +export const main = async (argv: string[]): Promise => { + const opts = parseOptions(argv); + + const assets = collectAssets(opts.assetDirs); + const notes = renderReleaseNotes(opts.version, assets, opts.dockerImage); + + const notesDir = resolve(repoRoot, 'build', 'output', 'release-notes'); + const notesFile = resolve(notesDir, `${opts.version}.md`); + if (!opts.dryRun) { + mkdirSync(notesDir, { recursive: true }); + writeFileSync(notesFile, notes, 'utf8'); + } + + const args = [ + 'release', + 'create', + `v${opts.version}`, + '--repo', + opts.repo, + '--title', + `MTConnect.NET ${opts.version}`, + '--notes-file', + notesFile, + '--prerelease', + ...assets, + ]; + await run('gh', args, { dryRun: opts.dryRun, cwd: repoRoot }); +}; + +const invokedDirectly = (() => { + const entry = process.argv[1]; + if (!entry) return false; + try { + return new URL(`file://${entry}`).href === import.meta.url; + } catch { + return false; + } +})(); + +if (invokedDirectly) { + main(process.argv.slice(2)).catch((err) => { + process.stderr.write(`gh-release-create: ${(err as Error).message}\n`); + process.exit(1); + }); +} diff --git a/tools/release/nuget-push.ts b/tools/release/nuget-push.ts new file mode 100644 index 000000000..df63be6ff --- /dev/null +++ b/tools/release/nuget-push.ts @@ -0,0 +1,106 @@ +#!/usr/bin/env -S npx tsx +/** + * Push every `.nupkg` in a directory to a NuGet feed. + * + * Ported from `build/MTConnect.NET.Builder/Parts/libraries/Nuget.cs` + * (Publish command). Uses a classic NuGet API key rather than OIDC — + * the switch to OIDC-signed publishes is a follow-up (Patrick asked + * to defer signing entirely for phase 1). + * + * Usage: + * tsx tools/release/nuget-push.ts --input [--source ] + * [--api-key ] [--dry-run] + * + * When `--api-key` is omitted the script reads `NUGET_API_KEY` from + * the environment; a missing key throws unless `--dry-run` is set. + */ + +import { readdirSync, existsSync } from 'node:fs'; +import { resolve } from 'node:path'; +import { parseArgs } from 'node:util'; +import { optionalEnv, parseDryRun, run } from './shell.ts'; + +/** Repo root — used to default `--input` to the Pack script's output. */ +const repoRoot = resolve(new URL('../../', import.meta.url).pathname); + +/** CLI options. */ +type Options = { + input: string; + source: string; + apiKey: string | undefined; + dryRun: boolean; +}; + +/** Parse argv into strongly-typed `Options`. */ +const parseOptions = (argv: string[]): Options => { + const { dryRun, rest } = parseDryRun(argv); + const { values } = parseArgs({ + args: rest, + options: { + input: { type: 'string' }, + source: { type: 'string' }, + 'api-key': { type: 'string' }, + }, + }); + return { + input: values.input ?? resolve(repoRoot, 'build', 'output', 'nupkg'), + source: values.source ?? 'https://api.nuget.org/v3/index.json', + apiKey: values['api-key'] ?? optionalEnv('NUGET_API_KEY'), + dryRun, + }; +}; + +/** Push each .nupkg in the input directory sequentially. Symbol packages + * (`.snupkg`) are skipped from the explicit iteration because `dotnet + * nuget push` automatically pushes the matching symbol package alongside + * its parent .nupkg when both live in the same directory. */ +export const main = async (argv: string[]): Promise => { + const opts = parseOptions(argv); + + if (!existsSync(opts.input)) { + throw new Error(`Input directory not found: ${opts.input}`); + } + if (!opts.apiKey && !opts.dryRun) { + throw new Error( + 'NuGet API key not provided. Set NUGET_API_KEY or pass --api-key. Use --dry-run to inspect commands only.', + ); + } + + const packages = readdirSync(opts.input).filter((f) => f.endsWith('.nupkg')); + if (packages.length === 0) { + throw new Error(`No .nupkg files found in ${opts.input}`); + } + + for (const pkg of packages) { + const path = resolve(opts.input, pkg); + const args = [ + 'nuget', + 'push', + path, + '--source', + opts.source, + '--skip-duplicate', + ]; + if (opts.apiKey) { + args.push('--api-key', opts.apiKey); + } + await run('dotnet', args, { dryRun: opts.dryRun }); + } +}; + +const invokedDirectly = (() => { + const entry = process.argv[1]; + if (!entry) return false; + try { + return new URL(`file://${entry}`).href === import.meta.url; + } catch { + return false; + } +})(); + +if (invokedDirectly) { + main(process.argv.slice(2)).catch((err) => { + process.stderr.write(`nuget-push: ${(err as Error).message}\n`); + process.exit(1); + }); +} diff --git a/tools/release/pack.ts b/tools/release/pack.ts new file mode 100644 index 000000000..457b0fc41 --- /dev/null +++ b/tools/release/pack.ts @@ -0,0 +1,106 @@ +#!/usr/bin/env -S npx tsx +/** + * `dotnet pack` every shipped library into `/build/output/nupkg/`. + * + * Ported from `build/MTConnect.NET.Builder/Parts/libraries/Nuget.cs` + * (which is retained for the manual stable-release workflow). Runs + * `dotnet pack MTConnect.NET.sln` once with `-c Release`, letting the + * .sln filter select every project that opts in via `IsPackable=true` + * in its `.csproj`. Emits a stable output directory the downstream + * `nuget-push.ts` and `gh-release-create.ts` scripts consume. + * + * Usage: + * tsx tools/release/pack.ts --version [--output ] [--dry-run] + * + * When `--dry-run` is passed, the underlying `dotnet` invocation is + * echoed but not executed — used by the tools' verification pass. + */ + +import { mkdirSync, existsSync, rmSync } from 'node:fs'; +import { resolve } from 'node:path'; +import { parseArgs } from 'node:util'; +import { parseDryRun, run } from './shell.ts'; + +/** Repo root — computed once, used to resolve the .sln and default + * output directory. The scripts are always launched from the repo + * root by the workflows; the calculation stays honest either way. */ +const repoRoot = resolve(new URL('../../', import.meta.url).pathname); + +/** CLI options parsed via `node:util.parseArgs`. `output` defaults to + * `/build/output/nupkg` so per-version outputs are colocated + * with the existing Builder layout. */ +type Options = { + version: string; + output: string; + dryRun: boolean; +}; + +/** Parse argv into strongly-typed `Options`. Fails fast on missing + * `--version`; no other flag is required. */ +const parseOptions = (argv: string[]): Options => { + const { dryRun, rest } = parseDryRun(argv); + const { values } = parseArgs({ + args: rest, + options: { + version: { type: 'string' }, + output: { type: 'string' }, + }, + }); + if (!values.version) { + throw new Error('--version is required (e.g. --version 7.0.0-dev.42)'); + } + return { + version: values.version, + output: values.output ?? resolve(repoRoot, 'build', 'output', 'nupkg'), + dryRun, + }; +}; + +/** Entry point — packs every packable project in the solution into + * the target output directory. */ +export const main = async (argv: string[]): Promise => { + const opts = parseOptions(argv); + + // Reset the output dir so a repeated invocation does not accumulate + // stale .nupkg files from an earlier build. + if (existsSync(opts.output) && !opts.dryRun) { + rmSync(opts.output, { recursive: true, force: true }); + } + if (!opts.dryRun) mkdirSync(opts.output, { recursive: true }); + + const slnPath = resolve(repoRoot, 'MTConnect.NET.sln'); + + const args = [ + 'pack', + slnPath, + '-c', + 'Release', + '--nologo', + `-p:PackageVersion=${opts.version}`, + '-p:IncludeSymbols=true', + '-p:SymbolPackageFormat=snupkg', + '-p:ContinuousIntegrationBuild=true', + '-p:Deterministic=true', + '-p:EmbedUntrackedSources=true', + '--output', + opts.output, + ]; + await run('dotnet', args, { dryRun: opts.dryRun, cwd: repoRoot }); +}; + +const invokedDirectly = (() => { + const entry = process.argv[1]; + if (!entry) return false; + try { + return new URL(`file://${entry}`).href === import.meta.url; + } catch { + return false; + } +})(); + +if (invokedDirectly) { + main(process.argv.slice(2)).catch((err) => { + process.stderr.write(`pack: ${(err as Error).message}\n`); + process.exit(1); + }); +} diff --git a/tools/release/sbom.ts b/tools/release/sbom.ts new file mode 100644 index 000000000..0c9dcf91c --- /dev/null +++ b/tools/release/sbom.ts @@ -0,0 +1,131 @@ +#!/usr/bin/env -S npx tsx +/** + * Generate SPDX SBOMs for the release artefacts. + * + * Two flavours in one script: + * 1. `--nuget` — invokes `dotnet sbom-tool generate` against the + * built .nupkg output so each package ships an in-tree SBOM + * alongside its manifest. The tool is expected to be installed + * as a global dotnet tool (`dotnet tool install --global + * Microsoft.Sbom.DotNetTool`) before this script runs; the + * release workflow's `sbom` job does that in a preceding step. + * 2. `--docker ` — invokes `docker scout sbom + * --format spdx-json` against a locally-built image and writes + * the result to `/docker--.spdx.json`. + * + * Usage: + * tsx tools/release/sbom.ts --nuget [--input ] [--output ] [--dry-run] + * tsx tools/release/sbom.ts --docker [--output ] [--dry-run] + */ + +import { existsSync, mkdirSync } from 'node:fs'; +import { resolve } from 'node:path'; +import { parseArgs } from 'node:util'; +import { parseDryRun, run } from './shell.ts'; + +/** Repo root — used for default input/output paths. */ +const repoRoot = resolve(new URL('../../', import.meta.url).pathname); + +/** CLI options — `nuget` and `docker` are mutually exclusive top-level + * modes. Exactly one must be provided. */ +type Options = { + mode: 'nuget' | 'docker'; + input: string; + output: string; + dockerImage: string | undefined; + dryRun: boolean; +}; + +/** Parse argv into strongly-typed `Options`. */ +const parseOptions = (argv: string[]): Options => { + const { dryRun, rest } = parseDryRun(argv); + const { values } = parseArgs({ + args: rest, + options: { + nuget: { type: 'boolean', default: false }, + docker: { type: 'string' }, + input: { type: 'string' }, + output: { type: 'string' }, + }, + }); + if (!values.nuget && !values.docker) { + throw new Error('Either --nuget or --docker is required'); + } + if (values.nuget && values.docker) { + throw new Error('--nuget and --docker are mutually exclusive'); + } + return { + mode: values.nuget ? 'nuget' : 'docker', + input: values.input ?? resolve(repoRoot, 'build', 'output', 'nupkg'), + output: values.output ?? resolve(repoRoot, 'build', 'output', 'sbom'), + dockerImage: values.docker, + dryRun, + }; +}; + +/** SBOM generation dispatch. Delegates to the tool best suited to each + * artefact class; both branches write into `/`. */ +export const main = async (argv: string[]): Promise => { + const opts = parseOptions(argv); + if (!opts.dryRun && !existsSync(opts.output)) { + mkdirSync(opts.output, { recursive: true }); + } + + if (opts.mode === 'nuget') { + // `sbom-tool generate` scans the whole build output and emits one + // manifest describing every .nupkg + its transitive dependency + // graph (as reported by NuGet's project.assets.json). + const args = [ + 'sbom-tool', + 'generate', + '-b', + opts.input, + '-bc', + repoRoot, + '-pn', + 'MTConnect.NET', + '-ps', + 'TrakHound Inc.', + '-nsb', + 'https://github.com/TrakHound/MTConnect.NET', + '-m', + opts.output, + ]; + await run('dotnet', args, { dryRun: opts.dryRun }); + return; + } + + // Docker mode — the image should already be present locally (built + // by `docker-build.ts`). `docker scout sbom` streams JSON on stdout; + // capture with the shell redirect the workflow wires up. + if (!opts.dockerImage) throw new Error('--docker image tag is required in docker mode'); + const slug = opts.dockerImage.replace(/[^A-Za-z0-9._-]/g, '_'); + const outFile = resolve(opts.output, `${slug}.spdx.json`); + const args = [ + 'scout', + 'sbom', + '--format', + 'spdx', + '--output', + outFile, + opts.dockerImage, + ]; + await run('docker', args, { dryRun: opts.dryRun }); +}; + +const invokedDirectly = (() => { + const entry = process.argv[1]; + if (!entry) return false; + try { + return new URL(`file://${entry}`).href === import.meta.url; + } catch { + return false; + } +})(); + +if (invokedDirectly) { + main(process.argv.slice(2)).catch((err) => { + process.stderr.write(`sbom: ${(err as Error).message}\n`); + process.exit(1); + }); +} diff --git a/tools/release/shell.ts b/tools/release/shell.ts new file mode 100644 index 000000000..9ccbecc6a --- /dev/null +++ b/tools/release/shell.ts @@ -0,0 +1,105 @@ +/** + * Shared shell-out helper for the `tools/release/` scripts. + * + * Every release script eventually shells out to `dotnet`, `docker`, or + * `gh`, streaming the child's stdout/stderr to the CI log so a + * workflow-log tail is enough to diagnose a failed run. The helper + * exists to (a) surface non-zero exits as thrown errors so `main()` + * bodies stay linear and (b) support a repo-wide `--dry-run` flag that + * echoes the command it would have run without executing anything. + * + * No script pipes command output through a variable — every subprocess + * inherits stdio, so a long `dotnet pack` remains observable at + * runtime instead of surfacing as a wall of text at the end. + */ + +import { spawn } from 'node:child_process'; + +/** Whether to actually spawn subprocesses. When true, commands are + * logged but not executed — used by the "no push" verification runs + * the release scripts do on every branch. */ +export type DryRun = boolean; + +/** + * Run one command with inherited stdio. Rejects with a descriptive + * error when the child exits non-zero. Under `dryRun`, prints the + * command it would have run and resolves immediately. + * + * @param cmd — executable name (resolved via PATH). + * @param args — argv passed verbatim; no shell expansion. + * @param opts — `dryRun` swaps the spawn for a stdout log line; `cwd` + * sets the working directory; `env` adds to `process.env`. + */ +export const run = async ( + cmd: string, + args: string[], + opts: { dryRun?: DryRun; cwd?: string; env?: NodeJS.ProcessEnv } = {}, +): Promise => { + const rendered = renderCmd(cmd, args); + if (opts.dryRun) { + process.stdout.write(`[dry-run] ${rendered}\n`); + return; + } + process.stdout.write(`+ ${rendered}\n`); + await new Promise((resolve, reject) => { + const child = spawn(cmd, args, { + stdio: 'inherit', + cwd: opts.cwd, + env: { ...process.env, ...opts.env }, + }); + child.on('error', reject); + child.on('exit', (code, signal) => { + if (code === 0) return resolve(); + const reason = signal ? `signal ${signal}` : `exit ${code}`; + reject(new Error(`${rendered} failed (${reason})`)); + }); + }); +}; + +/** Render a command for logging — quoting any arg that contains + * whitespace or shell-special characters. Human-readable, not + * round-trip parseable. */ +export const renderCmd = (cmd: string, args: string[]): string => { + return [cmd, ...args.map(quoteForLog)].join(' '); +}; + +/** Wrap in double quotes if the arg contains anything shell would + * otherwise re-tokenise. Interior double quotes are backslash-escaped. */ +const quoteForLog = (arg: string): string => { + if (/^[A-Za-z0-9._\-/=:]+$/.test(arg)) return arg; + return `"${arg.replace(/"/g, '\\"')}"`; +}; + +/** Parse a `--dry-run` flag out of an argv list, returning both the + * boolean and the remaining args. Kept trivial so the scripts do not + * need a dependency for one flag. */ +export const parseDryRun = (argv: string[]): { dryRun: DryRun; rest: string[] } => { + const rest: string[] = []; + let dryRun = false; + for (const arg of argv) { + if (arg === '--dry-run') { + dryRun = true; + } else { + rest.push(arg); + } + } + return { dryRun, rest }; +}; + +/** Look up a required environment variable. Throws when missing so a + * workflow step fails loudly instead of publishing an empty-versioned + * artifact. */ +export const requireEnv = (name: string): string => { + const v = process.env[name]; + if (!v || v.trim().length === 0) { + throw new Error(`Environment variable ${name} is required but not set.`); + } + return v; +}; + +/** Look up an optional environment variable, returning `undefined` + * when unset. Present so call sites read symmetrically. */ +export const optionalEnv = (name: string): string | undefined => { + const v = process.env[name]; + return v && v.trim().length > 0 ? v : undefined; +}; diff --git a/tools/tsconfig.json b/tools/tsconfig.json new file mode 100644 index 000000000..93d684543 --- /dev/null +++ b/tools/tsconfig.json @@ -0,0 +1,25 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ES2022", + "moduleResolution": "bundler", + "esModuleInterop": true, + "allowSyntheticDefaultImports": true, + "strict": true, + "noImplicitAny": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedIndexedAccess": true, + "skipLibCheck": true, + "resolveJsonModule": true, + "isolatedModules": true, + "verbatimModuleSyntax": false, + "allowImportingTsExtensions": true, + "lib": ["ES2022"], + "types": ["node"], + "noEmit": true + }, + "include": ["**/*.ts"], + "exclude": ["node_modules", "**/node_modules"] +} From cfc589d5b59c2f87f370bd7b61393a42c744052c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Otto=20Boly=C3=B3s?= Date: Wed, 19 Aug 2026 15:46:44 +0200 Subject: [PATCH 02/15] test(ci): close branch-coverage gaps in semver-bump pure helpers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extend the in-house `test()`-shim suite from 20 to 31 assertions to pin every branch documented in the semver-bump.ts docstrings that the initial pass under-covered. Additions cover: - bumpKindFor: every documented patch type (`refactor`, `style`, `perf`, `test`, `ci`, `revert`), unknown conventional-shaped types that must fall through to `none`, case-insensitivity across the type + bang arms, and every BREAKING-CHANGE-anchor variant (start-of-body vs post-newline, hyphenated vs spaced, lower vs mixed case, mid-word rejection). - aggregateBump: the all-`none` fall-through arm distinct from empty. - applyBump: v-prefix strip for every kind × prefix combination, and the `''` / bare-`v` invalid-input arms of the throw path. - isStableCutMarker: the subject.trim() branch, case-sensitivity on the marker literal, and PR-suffix well-formedness. - isDevTag: the boundary rejects (`-dev` no counter, trailing dot, non-numeric counter, missing `v` prefix, leading whitespace) and the zero-counter acceptance. RED-verified each new-test class by temporarily mutating the corresponding SUT line and observing the failure before restoring: - drop `toLowerCase()` → case-insensitive test failed; - collapse `(^|\n)` → `^` → BREAKING newline-anchor test failed; - remove `refactor` from the known set → patch-type test failed; - drop `$` from the isDevTag regex → boundary test failed; - drop `.trim()` from isStableCutMarker → whitespace test failed. SUT restored bit-for-bit before commit. --- tools/ci/semver-bump.test.ts | 132 +++++++++++++++++++++++++++++++++++ 1 file changed, 132 insertions(+) diff --git a/tools/ci/semver-bump.test.ts b/tools/ci/semver-bump.test.ts index 758c85ec4..bf07eb304 100644 --- a/tools/ci/semver-bump.test.ts +++ b/tools/ci/semver-bump.test.ts @@ -62,10 +62,69 @@ test('bumpKindFor: chore/docs/build → patch', () => { assert.equal(bumpKindFor('build(ci): tweak workflow', ''), 'patch'); }); +test('bumpKindFor: every documented patch type resolves to patch', () => { + // Doc lists `fix|chore|docs|style|refactor|perf|test|build|ci|revert`; + // pin every arm so a docstring drift or regex tweak surfaces as a + // failure rather than a silently-lost bump kind. + for (const type of ['refactor', 'style', 'perf', 'test', 'ci', 'revert']) { + assert.equal(bumpKindFor(`${type}: do the thing`, ''), 'patch', type); + assert.equal(bumpKindFor(`${type}(scope): do the thing`, ''), 'patch', `${type}(scope)`); + } +}); + +test('bumpKindFor: unknown conventional-shaped type → none', () => { + // `foo:` matches the type regex but is not in the known-types set, + // so it falls through to `none`. Distinguishes the "no match" arm + // from the "matched but unknown" arm — both return `none` but via + // different code paths. + assert.equal(bumpKindFor('foo: bar', ''), 'none'); + assert.equal(bumpKindFor('wip(scope): thing', ''), 'none'); +}); + +test('bumpKindFor: case-insensitive type recognition', () => { + // Docstring: "subject starts with `feat` (case-insensitive) → minor". + // The `type.toLowerCase()` branch is only exercised end-to-end when + // the input arrives mixed-case; pin the contract. + assert.equal(bumpKindFor('FEAT: shout', ''), 'minor'); + assert.equal(bumpKindFor('Feat(agent): pascal', ''), 'minor'); + assert.equal(bumpKindFor('FIX: shout', ''), 'patch'); + assert.equal(bumpKindFor('Fix(agent): pascal', ''), 'patch'); + // Bang also — the `[a-zA-Z]+` character class covers both cases. + assert.equal(bumpKindFor('FEAT!: shout', ''), 'major'); + assert.equal(bumpKindFor('Fix(agent)!: pascal', ''), 'major'); +}); + +test('bumpKindFor: BREAKING CHANGE detection covers each anchor', () => { + // The regex `(^|\n)BREAKING[ -]CHANGE:` has two anchors — start-of- + // body and post-newline. The existing test only hits the start-of- + // body arm; pin the newline arm too. + assert.equal( + bumpKindFor('feat: add', 'Some prose paragraph.\n\nBREAKING CHANGE: renamed field'), + 'major', + ); + assert.equal( + bumpKindFor('feat: add', 'Prose\nBREAKING-CHANGE: renamed'), + 'major', + ); + // Case-insensitivity on the footer marker (per `/i` flag). + assert.equal( + bumpKindFor('feat: add', 'breaking change: lowercased'), + 'major', + ); + // A `BREAKING CHANGE` mid-word (no leading anchor) must NOT trigger. + assert.equal( + bumpKindFor('feat: add', 'This is not-a-BREAKING CHANGE: really'), + 'minor', + ); +}); + test('bumpKindFor: non-conventional → none', () => { assert.equal(bumpKindFor('WIP', ''), 'none'); assert.equal(bumpKindFor('add stuff', ''), 'none'); assert.equal(bumpKindFor('Merge branch xyz', ''), 'none'); + // Empty subject — the trim collapses it, no type match, `none`. + assert.equal(bumpKindFor('', ''), 'none'); + assert.equal(bumpKindFor(' ', ''), 'none'); }); // ─── aggregateBump ────────────────────────────────────────────── @@ -85,6 +144,14 @@ test('aggregateBump: empty → none', () => { assert.equal(aggregateBump([]), 'none'); }); +test('aggregateBump: all-none → none (fall-through arm)', () => { + // Distinct from the empty case: exercises the trailing `return 'none'` + // after every `includes(...)` check misses. A single `none` and a + // list of `none`s must both fall through. + assert.equal(aggregateBump(['none']), 'none'); + assert.equal(aggregateBump(['none', 'none', 'none']), 'none'); +}); + // ─── applyBump ────────────────────────────────────────────────── test('applyBump: major → X+1.0.0', () => { assert.equal(applyBump('v6.6.0', 'major'), '7.0.0'); @@ -105,6 +172,20 @@ test('applyBump: none → patch bump (avoids version collision)', () => { test('applyBump: rejects invalid base', () => { assert.throws(() => applyBump('nope', 'minor'), /not a valid semver/); + // Also rejects an empty string and a v-prefix-only string. + assert.throws(() => applyBump('', 'minor'), /not a valid semver/); + assert.throws(() => applyBump('v', 'minor'), /not a valid semver/); +}); + +test('applyBump: strips v-prefix for every kind', () => { + // The `v` strip is a single line but each kind arm consumes the + // stripped value differently. Pin every kind × prefix combination. + assert.equal(applyBump('v6.6.0', 'minor'), '6.7.0'); + assert.equal(applyBump('6.6.0', 'minor'), '6.7.0'); + assert.equal(applyBump('v6.6.0', 'patch'), '6.6.1'); + assert.equal(applyBump('6.6.0', 'patch'), '6.6.1'); + assert.equal(applyBump('v6.6.0', 'none'), '6.6.1'); + assert.equal(applyBump('6.6.0', 'none'), '6.6.1'); }); // ─── isStableCutMarker ────────────────────────────────────────── @@ -124,6 +205,32 @@ test('isStableCutMarker: other chore(release) messages rejected', () => { assert.equal(isStableCutMarker('chore: publish new stable'), false); }); +test('isStableCutMarker: whitespace-trimmed', () => { + // Docstring: "Case-sensitive on the marker itself". Trim behaviour + // is implicit via `subject.trim()` — pin so a future refactor that + // drops the trim does not silently break real squash-merge subjects. + assert.equal(isStableCutMarker(' chore(release): publish new stable\n'), true); + assert.equal(isStableCutMarker('\tchore(release): publish new stable (#42)\t'), true); +}); + +test('isStableCutMarker: case-sensitivity on the marker literal', () => { + // Docstring: "Case-sensitive on the marker itself so a random + // `chore(release): tweak wording` does not reset the counter." + assert.equal(isStableCutMarker('Chore(release): publish new stable'), false); + assert.equal(isStableCutMarker('chore(Release): publish new stable'), false); + assert.equal(isStableCutMarker('chore(release): Publish new stable'), false); +}); + +test('isStableCutMarker: PR suffix must be well-formed', () => { + // A `(#` opener without a digit sequence must not match, and a `#` + // without digits after the space likewise. Guards the `(#\d+)?` + // arm from over-liberal matching. + assert.equal(isStableCutMarker('chore(release): publish new stable (#)'), false); + assert.equal(isStableCutMarker('chore(release): publish new stable (#abc)'), false); + assert.equal(isStableCutMarker('chore(release): publish new stable (123)'), false); + assert.equal(isStableCutMarker('chore(release): publish new stable#123'), false); +}); + // ─── isDevTag ─────────────────────────────────────────────────── test('isDevTag: accepts vX.Y.Z-dev.N', () => { assert.equal(isDevTag('v6.6.0-dev.42'), true); @@ -136,4 +243,29 @@ test('isDevTag: rejects stable + rc tags', () => { assert.equal(isDevTag('v6.6.0-dev.1.2'), false); }); +test('isDevTag: rejects malformed dev-adjacent shapes', () => { + // Boundary cases that look almost right but must not match: + // - missing counter (`-dev`); + // - trailing separator (`-dev.`); + // - non-numeric counter (`-dev.rc`); + // - missing `v` prefix (`6.6.0-dev.1`); + // - extra prefix (`vv6.6.0-dev.1`); + // - leading whitespace (regex is anchored, no `.trim()`). + assert.equal(isDevTag('v6.6.0-dev'), false); + assert.equal(isDevTag('v6.6.0-dev.'), false); + assert.equal(isDevTag('v6.6.0-dev.rc'), false); + assert.equal(isDevTag('6.6.0-dev.1'), false); + assert.equal(isDevTag('vv6.6.0-dev.1'), false); + assert.equal(isDevTag(' v6.6.0-dev.1'), false); + assert.equal(isDevTag(''), false); +}); + +test('isDevTag: accepts zero counter', () => { + // The regex allows `\d+` including a leading zero — pin the boundary + // so a future author does not narrow it accidentally. + assert.equal(isDevTag('v6.6.0-dev.0'), true); + assert.equal(isDevTag('v0.0.0-dev.1'), true); + assert.equal(isDevTag('v10.20.30-dev.400'), true); +}); + process.stdout.write(`\n${passed} assertions passed.\n`); From 365c0a59db7a02b822d1cdae4b10cdb61a3fc1ee Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Otto=20Boly=C3=B3s?= Date: Wed, 19 Aug 2026 15:49:32 +0200 Subject: [PATCH 03/15] test(release): add unit suite for shell.ts pure helpers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `tools/release/shell.ts` shipped with zero unit tests despite being the shared shell-out layer every release script imports. Land a 20-assertion suite that mirrors the in-house `test()`-shim shape used by `tools/ci/semver-bump.test.ts`, so the tools/ dir has one runner convention across both directories. Coverage: - renderCmd: safe-charset pass-through, whitespace-triggered quoting, interior-double-quote escape, shell-special-char coverage (`$`, `;`, `|`, `*`, space, empty string), empty argv. - parseDryRun: presence, absence, empty argv, positional invariance (front/middle/end), multiple occurrences, and the intentional `--dry-run=true` non-recognition. - requireEnv: present, absent (throws with var name), empty and whitespace-only values treated as missing. - optionalEnv: present, absent (returns undefined), empty and whitespace-only values treated as undefined. - run: dry-run path only — logs the rendered command via process.stdout.write and returns without spawning. Live-spawn path stays in the integration matrix. Test-shim refactored from immediate-execute to a collect-then-await main() so async cases (the two `run` dry-run assertions) do not silently drop before the runtime exits. RED-verified: widening the `quoteForLog` whitelist to include a space made the whitespace-quoting test fail; loosening `parseDryRun`'s equality check to `startsWith` broke the `--dry-run=true` test. Both mutations were reverted bit-for-bit and the suite goes 20/20 green. --- tools/release/shell.test.ts | 243 ++++++++++++++++++++++++++++++++++++ 1 file changed, 243 insertions(+) create mode 100644 tools/release/shell.test.ts diff --git a/tools/release/shell.test.ts b/tools/release/shell.test.ts new file mode 100644 index 000000000..af5f7f72a --- /dev/null +++ b/tools/release/shell.test.ts @@ -0,0 +1,243 @@ +#!/usr/bin/env -S npx tsx +/** + * Unit tests for the pure helpers in `shell.ts` — the shared shell-out + * layer every `tools/release/*` script imports. Run with: + * + * tsx tools/release/shell.test.ts + * + * Exits 0 when every assertion passes, non-zero otherwise. Uses an + * in-house `test(name, fn)` collect-then-await shim so both sync and + * async cases can share the same runner without pulling in Jest/Vitest. + * + * The `run()` export is exercised via its `dryRun` mode — that path + * returns without spawning a subprocess, and its stdout side-effect is + * asserted by patching `process.stdout.write`. The live-spawn path is + * out of scope for a pure-unit suite (belongs to the integration + * matrix that already runs `--dry-run` end-to-end). + */ + +import { strict as assert } from 'node:assert'; +import { + optionalEnv, + parseDryRun, + renderCmd, + requireEnv, + run, +} from './shell.ts'; + +const cases: Array<{ name: string; fn: () => void | Promise }> = []; +const test = (name: string, fn: () => void | Promise): void => { + cases.push({ name, fn }); +}; + +// stdout-capture helper — patches `process.stdout.write` for the +// duration of `fn`, returns everything written. Used to assert the +// dry-run log format without inventing a mock framework. +const captureStdout = async (fn: () => void | Promise): Promise => { + const original = process.stdout.write.bind(process.stdout); + let buf = ''; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (process.stdout as any).write = (chunk: string | Uint8Array): boolean => { + buf += typeof chunk === 'string' ? chunk : Buffer.from(chunk).toString('utf8'); + return true; + }; + try { + await fn(); + } finally { + (process.stdout as unknown as { write: typeof original }).write = original; + } + return buf; +}; + +// ─── renderCmd ────────────────────────────────────────────────── +test('renderCmd: simple args stay unquoted', () => { + assert.equal(renderCmd('git', ['log', '--oneline']), 'git log --oneline'); + assert.equal(renderCmd('dotnet', ['pack', '-c', 'Release']), 'dotnet pack -c Release'); +}); + +test('renderCmd: safe-charset args stay unquoted', () => { + // `A-Za-z0-9._-/=:` is the whitelist — a NuGet-style path is safe. + assert.equal( + renderCmd('dotnet', ['nuget', 'push', '/out/foo.1.2.3.nupkg', '--source', 'https://api.nuget.org/v3/index.json']), + 'dotnet nuget push /out/foo.1.2.3.nupkg --source https://api.nuget.org/v3/index.json', + ); +}); + +test('renderCmd: args with whitespace get double-quoted', () => { + assert.equal( + renderCmd('gh', ['release', 'create', '--title', 'MTConnect.NET 7.0.0-dev.42']), + 'gh release create --title "MTConnect.NET 7.0.0-dev.42"', + ); +}); + +test('renderCmd: interior double quotes are backslash-escaped', () => { + assert.equal( + renderCmd('sh', ['-c', 'echo "hi"']), + 'sh -c "echo \\"hi\\""', + ); +}); + +test('renderCmd: shell-special chars force quoting', () => { + // `$`, `` ` ``, `*`, `;`, `&`, `|`, `(`, `)`, `<`, `>` are all outside + // the whitelist, so each must round-trip as a quoted arg. + assert.equal(renderCmd('e', ['a$b']), 'e "a$b"'); + assert.equal(renderCmd('e', ['a;b']), 'e "a;b"'); + assert.equal(renderCmd('e', ['a|b']), 'e "a|b"'); + assert.equal(renderCmd('e', ['a b']), 'e "a b"'); + assert.equal(renderCmd('e', ['a*b']), 'e "a*b"'); + assert.equal(renderCmd('e', ['']), 'e ""'); +}); + +test('renderCmd: empty argv renders as the bare cmd', () => { + assert.equal(renderCmd('gh', []), 'gh'); +}); + +// ─── parseDryRun ──────────────────────────────────────────────── +test('parseDryRun: --dry-run flag detected, stripped from rest', () => { + const r = parseDryRun(['--version', '1.0.0', '--dry-run']); + assert.equal(r.dryRun, true); + assert.deepEqual(r.rest, ['--version', '1.0.0']); +}); + +test('parseDryRun: absent flag → dryRun false', () => { + const r = parseDryRun(['--version', '1.0.0']); + assert.equal(r.dryRun, false); + assert.deepEqual(r.rest, ['--version', '1.0.0']); +}); + +test('parseDryRun: empty argv → dryRun false, empty rest', () => { + const r = parseDryRun([]); + assert.equal(r.dryRun, false); + assert.deepEqual(r.rest, []); +}); + +test('parseDryRun: flag in any position, single occurrence', () => { + // Guard the reduce order — a `--dry-run` at the front, middle, or + // end must produce the same result. + const a = parseDryRun(['--dry-run', '--x', 'y']); + const b = parseDryRun(['--x', '--dry-run', 'y']); + const c = parseDryRun(['--x', 'y', '--dry-run']); + assert.deepEqual([a.dryRun, a.rest], [true, ['--x', 'y']]); + assert.deepEqual([b.dryRun, b.rest], [true, ['--x', 'y']]); + assert.deepEqual([c.dryRun, c.rest], [true, ['--x', 'y']]); +}); + +test('parseDryRun: multiple --dry-run flags remain truthy, none leak into rest', () => { + const r = parseDryRun(['--dry-run', '--x', '--dry-run']); + assert.equal(r.dryRun, true); + assert.deepEqual(r.rest, ['--x']); +}); + +test('parseDryRun: --dry-run=true is NOT recognised (equality-only match)', () => { + // Documented behaviour — the parser is `arg === '--dry-run'`, not + // `.startsWith`. `--dry-run=true` therefore lands in `rest` and + // `parseArgs` downstream will accept it separately if declared. + // Pin the current shape so a future author does not loosen it. + const r = parseDryRun(['--dry-run=true']); + assert.equal(r.dryRun, false); + assert.deepEqual(r.rest, ['--dry-run=true']); +}); + +// ─── requireEnv ───────────────────────────────────────────────── +test('requireEnv: present var returned as-is', () => { + const key = '__MTC_TEST_REQUIRE_ENV_PRESENT'; + process.env[key] = 'hello'; + try { + assert.equal(requireEnv(key), 'hello'); + } finally { + delete process.env[key]; + } +}); + +test('requireEnv: missing var throws with the var name in the message', () => { + const key = '__MTC_TEST_REQUIRE_ENV_MISSING'; + delete process.env[key]; + assert.throws(() => requireEnv(key), new RegExp(key)); +}); + +test('requireEnv: empty and whitespace-only values treated as missing', () => { + const key = '__MTC_TEST_REQUIRE_ENV_EMPTY'; + process.env[key] = ''; + try { + assert.throws(() => requireEnv(key), /required but not set/); + } finally { + delete process.env[key]; + } + process.env[key] = ' '; + try { + assert.throws(() => requireEnv(key), /required but not set/); + } finally { + delete process.env[key]; + } +}); + +// ─── optionalEnv ──────────────────────────────────────────────── +test('optionalEnv: present var returned as-is', () => { + const key = '__MTC_TEST_OPTIONAL_ENV_PRESENT'; + process.env[key] = 'value'; + try { + assert.equal(optionalEnv(key), 'value'); + } finally { + delete process.env[key]; + } +}); + +test('optionalEnv: missing var returns undefined (no throw)', () => { + const key = '__MTC_TEST_OPTIONAL_ENV_MISSING'; + delete process.env[key]; + assert.equal(optionalEnv(key), undefined); +}); + +test('optionalEnv: empty and whitespace-only values treated as undefined', () => { + const key = '__MTC_TEST_OPTIONAL_ENV_EMPTY'; + process.env[key] = ''; + try { + assert.equal(optionalEnv(key), undefined); + } finally { + delete process.env[key]; + } + process.env[key] = ' '; + try { + assert.equal(optionalEnv(key), undefined); + } finally { + delete process.env[key]; + } +}); + +// ─── run (dry-run path only) ──────────────────────────────────── +test('run: dry-run logs the rendered cmd and does not spawn', async () => { + const out = await captureStdout(async () => { + await run('docker', ['push', 'x:1.0'], { dryRun: true }); + }); + assert.equal(out, '[dry-run] docker push x:1.0\n'); +}); + +test('run: dry-run preserves argument quoting from renderCmd', async () => { + const out = await captureStdout(async () => { + await run('gh', ['release', 'create', '--title', 'MTConnect.NET 7.0.0-dev.42'], { + dryRun: true, + }); + }); + assert.equal(out, '[dry-run] gh release create --title "MTConnect.NET 7.0.0-dev.42"\n'); +}); + +// ─── main runner ──────────────────────────────────────────────── +const main = async (): Promise => { + let passed = 0; + for (const c of cases) { + try { + await c.fn(); + } catch (err) { + process.stderr.write(` FAIL ${c.name}\n`); + throw err; + } + passed += 1; + process.stdout.write(` ok ${c.name}\n`); + } + process.stdout.write(`\n${passed} assertions passed.\n`); +}; + +main().catch((err) => { + process.stderr.write(`${(err as Error).stack ?? err}\n`); + process.exit(1); +}); From 4ada71fad0b3864ccec14bd3f39134c3d858a6ba Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Otto=20Boly=C3=B3s?= Date: Wed, 19 Aug 2026 15:56:22 +0200 Subject: [PATCH 04/15] test(release): add unit suites for every release/*.ts + wire npm test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit None of the six `tools/release/*.ts` scripts shipped with a unit test in the initial cut. Add per-script suites covering every pure helper that is safe to exercise without a live docker / dotnet / gh dependency: - docker-build.test.ts (8): archSuffixFor for both platforms, parseOptions happy path, --image default, --dry-run consumption, missing / unrecognized / absent --platform + --version errors. - docker-push.test.ts (10): archSuffixFor, per-arch push mode, manifest mode, --image override, --dry-run consumption, and every error arm (missing --version, neither --platform nor --manifest, mutex enforcement, unrecognized --platform). - gh-release-create.test.ts (14): parseOptions happy path + defaults + missing-version error; collectAssets across empty / missing / populated dirs with dotfile + hidden-dir filtering + multi-dir aggregation; renderReleaseNotes header, empty-assets sentinel, per-asset basename rendering, docker-section presence toggle, not-for-production prose invariant. - pack.test.ts (4): parseOptions happy path, default --output path, --dry-run consumption, missing --version error including the example-hint suffix. - nuget-push.test.ts (7): every default (input, source), the --api-key -> NUGET_API_KEY fallback ladder (flag beats env, env beats undefined), --dry-run consumption. - sbom.test.ts (9): --nuget vs --docker mode selection, mutex enforcement, missing-mode error, --input / --output defaults + overrides, --dry-run consumption. - shell.ts unit suite (from prior commit) reruns as part of the same runner. Also: - `refactor`: promote the previously module-private `archSuffixFor`, `parseOptions`, `collectAssets`, `renderReleaseNotes`, and each `Options` type to `export` so the test files can invoke them without indirection through `main()`. Zero runtime-behavior delta. - `chore`: add `tools/run-tests.ts` — a small discover-and-spawn runner that walks `tools/**/*.test.ts` and returns non-zero on any file's failure. Wired as the `test` npm script alongside the existing `typecheck` script. RED-verified each new suite by temporarily mutating the SUT and observing the corresponding test's failure: - swap archSuffixFor return arms -> docker-build test failed; - drop the platform/manifest mutex -> docker-push test failed; - render assets as absolute paths instead of backtick basenames -> gh-release-create test failed; - strip the example hint from pack's missing-version error -> pack test failed; - remove the NUGET_API_KEY env fallback -> nuget-push test failed; - drop the --nuget/--docker mutex -> sbom test failed. Each SUT restored bit-for-bit before commit; 103 assertions pass across the 8 test files under `npm test`. --- tools/package.json | 3 +- tools/release/docker-build.test.ts | 91 +++++++++++++ tools/release/docker-build.ts | 6 +- tools/release/docker-push.test.ts | 107 +++++++++++++++ tools/release/docker-push.ts | 6 +- tools/release/gh-release-create.test.ts | 168 ++++++++++++++++++++++++ tools/release/gh-release-create.ts | 8 +- tools/release/nuget-push.test.ts | 106 +++++++++++++++ tools/release/nuget-push.ts | 4 +- tools/release/pack.test.ts | 57 ++++++++ tools/release/pack.ts | 4 +- tools/release/sbom.test.ts | 83 ++++++++++++ tools/release/sbom.ts | 4 +- tools/run-tests.ts | 71 ++++++++++ 14 files changed, 701 insertions(+), 17 deletions(-) create mode 100644 tools/release/docker-build.test.ts create mode 100644 tools/release/docker-push.test.ts create mode 100644 tools/release/gh-release-create.test.ts create mode 100644 tools/release/nuget-push.test.ts create mode 100644 tools/release/pack.test.ts create mode 100644 tools/release/sbom.test.ts create mode 100644 tools/run-tests.ts diff --git a/tools/package.json b/tools/package.json index 603bb7be6..1b49b1028 100644 --- a/tools/package.json +++ b/tools/package.json @@ -8,7 +8,8 @@ "node": ">=20" }, "scripts": { - "typecheck": "tsc --noEmit" + "typecheck": "tsc --noEmit", + "test": "tsx run-tests.ts" }, "dependencies": { "@octokit/rest": "^21.0.0", diff --git a/tools/release/docker-build.test.ts b/tools/release/docker-build.test.ts new file mode 100644 index 000000000..703d08583 --- /dev/null +++ b/tools/release/docker-build.test.ts @@ -0,0 +1,91 @@ +#!/usr/bin/env -S npx tsx +/** + * Unit tests for the pure helpers exported by `docker-build.ts`. The + * spawning half (`main` shelling out to `docker buildx`) is out of + * scope for a pure-unit suite — its behaviour is covered by the + * integration matrix that runs `--dry-run` end-to-end. Run with: + * + * tsx tools/release/docker-build.test.ts + */ + +import { strict as assert } from 'node:assert'; +import { archSuffixFor, parseOptions } from './docker-build.ts'; + +const cases: Array<{ name: string; fn: () => void }> = []; +const test = (name: string, fn: () => void): void => { + cases.push({ name, fn }); +}; + +// ─── archSuffixFor ────────────────────────────────────────────── +test('archSuffixFor: linux/amd64 → amd64', () => { + assert.equal(archSuffixFor('linux/amd64'), 'amd64'); +}); + +test('archSuffixFor: linux/arm64 → arm64', () => { + assert.equal(archSuffixFor('linux/arm64'), 'arm64'); +}); + +// ─── parseOptions ─────────────────────────────────────────────── +test('parseOptions: happy path — every field populated', () => { + const o = parseOptions([ + '--version', '7.0.0-dev.42', + '--platform', 'linux/amd64', + '--image', 'myorg/mtc', + ]); + assert.equal(o.version, '7.0.0-dev.42'); + assert.equal(o.platform, 'linux/amd64'); + assert.equal(o.image, 'myorg/mtc'); + assert.equal(o.dryRun, false); +}); + +test('parseOptions: --image defaults to trakhound/mtconnect-agent', () => { + const o = parseOptions(['--version', '1.0.0', '--platform', 'linux/arm64']); + assert.equal(o.image, 'trakhound/mtconnect-agent'); + assert.equal(o.platform, 'linux/arm64'); +}); + +test('parseOptions: --dry-run is consumed pre-parseArgs and never leaks', () => { + const o = parseOptions(['--dry-run', '--version', '1.0.0', '--platform', 'linux/amd64']); + assert.equal(o.dryRun, true); + assert.equal(o.version, '1.0.0'); +}); + +test('parseOptions: missing --version throws', () => { + assert.throws( + () => parseOptions(['--platform', 'linux/amd64']), + /--version is required/, + ); +}); + +test('parseOptions: unrecognised --platform throws with the offending value', () => { + assert.throws( + () => parseOptions(['--version', '1.0.0', '--platform', 'darwin/arm64']), + /darwin\/arm64/, + ); + // linux/amd64/arm64 typo: + assert.throws( + () => parseOptions(['--version', '1.0.0', '--platform', 'linux/x86_64']), + /linux\/amd64 or linux\/arm64/, + ); +}); + +test('parseOptions: missing --platform names it in the error', () => { + assert.throws( + () => parseOptions(['--version', '1.0.0']), + /--platform/, + ); +}); + +// ─── main runner ──────────────────────────────────────────────── +let passed = 0; +for (const c of cases) { + try { + c.fn(); + } catch (err) { + process.stderr.write(` FAIL ${c.name}\n`); + throw err; + } + passed += 1; + process.stdout.write(` ok ${c.name}\n`); +} +process.stdout.write(`\n${passed} assertions passed.\n`); diff --git a/tools/release/docker-build.ts b/tools/release/docker-build.ts index 0e2a98a4f..6be11b699 100644 --- a/tools/release/docker-build.ts +++ b/tools/release/docker-build.ts @@ -32,12 +32,12 @@ const repoRoot = resolve(new URL('../../', import.meta.url).pathname); type Platform = 'linux/amd64' | 'linux/arm64'; /** Map platform to the arch-suffix used in the per-arch tag. */ -const archSuffixFor = (p: Platform): 'amd64' | 'arm64' => { +export const archSuffixFor = (p: Platform): 'amd64' | 'arm64' => { return p === 'linux/amd64' ? 'amd64' : 'arm64'; }; /** CLI options. */ -type Options = { +export type Options = { version: string; platform: Platform; image: string; @@ -45,7 +45,7 @@ type Options = { }; /** Parse argv into strongly-typed `Options`. */ -const parseOptions = (argv: string[]): Options => { +export const parseOptions = (argv: string[]): Options => { const { dryRun, rest } = parseDryRun(argv); const { values } = parseArgs({ args: rest, diff --git a/tools/release/docker-push.test.ts b/tools/release/docker-push.test.ts new file mode 100644 index 000000000..0d96042d8 --- /dev/null +++ b/tools/release/docker-push.test.ts @@ -0,0 +1,107 @@ +#!/usr/bin/env -S npx tsx +/** + * Unit tests for the pure helpers exported by `docker-push.ts`. The + * spawning half (`main` shelling out to `docker push` / `docker buildx + * imagetools`) is out of scope for the pure-unit suite. Run with: + * + * tsx tools/release/docker-push.test.ts + */ + +import { strict as assert } from 'node:assert'; +import { archSuffixFor, parseOptions } from './docker-push.ts'; + +const cases: Array<{ name: string; fn: () => void }> = []; +const test = (name: string, fn: () => void): void => { + cases.push({ name, fn }); +}; + +// ─── archSuffixFor ────────────────────────────────────────────── +test('archSuffixFor: linux/amd64 → amd64', () => { + assert.equal(archSuffixFor('linux/amd64'), 'amd64'); +}); + +test('archSuffixFor: linux/arm64 → arm64', () => { + assert.equal(archSuffixFor('linux/arm64'), 'arm64'); +}); + +// ─── parseOptions: per-arch push mode ─────────────────────────── +test('parseOptions: per-arch push — platform set, manifest false', () => { + const o = parseOptions(['--version', '1.0.0', '--platform', 'linux/amd64']); + assert.equal(o.version, '1.0.0'); + assert.equal(o.platform, 'linux/amd64'); + assert.equal(o.manifest, false); + assert.equal(o.image, 'trakhound/mtconnect-agent'); + assert.equal(o.dryRun, false); +}); + +test('parseOptions: per-arch push honours --image override', () => { + const o = parseOptions([ + '--version', '1.0.0', + '--platform', 'linux/arm64', + '--image', 'foo/bar', + ]); + assert.equal(o.image, 'foo/bar'); + assert.equal(o.platform, 'linux/arm64'); +}); + +// ─── parseOptions: manifest mode ──────────────────────────────── +test('parseOptions: manifest mode — platform undefined, manifest true', () => { + const o = parseOptions(['--version', '1.0.0', '--manifest']); + assert.equal(o.manifest, true); + assert.equal(o.platform, undefined); + assert.equal(o.image, 'trakhound/mtconnect-agent'); +}); + +// ─── parseOptions: --dry-run flag ─────────────────────────────── +test('parseOptions: --dry-run is consumed', () => { + const o = parseOptions(['--dry-run', '--version', '1.0.0', '--manifest']); + assert.equal(o.dryRun, true); + assert.equal(o.manifest, true); +}); + +// ─── parseOptions: error paths ────────────────────────────────── +test('parseOptions: missing --version throws', () => { + assert.throws( + () => parseOptions(['--platform', 'linux/amd64']), + /--version is required/, + ); +}); + +test('parseOptions: neither --platform nor --manifest throws', () => { + assert.throws( + () => parseOptions(['--version', '1.0.0']), + /Either --platform or --manifest must be provided/, + ); +}); + +test('parseOptions: --platform and --manifest are mutually exclusive', () => { + assert.throws( + () => parseOptions([ + '--version', '1.0.0', + '--platform', 'linux/amd64', + '--manifest', + ]), + /mutually exclusive/, + ); +}); + +test('parseOptions: unrecognised --platform throws', () => { + assert.throws( + () => parseOptions(['--version', '1.0.0', '--platform', 'linux/riscv64']), + /linux\/amd64 or linux\/arm64/, + ); +}); + +// ─── main runner ──────────────────────────────────────────────── +let passed = 0; +for (const c of cases) { + try { + c.fn(); + } catch (err) { + process.stderr.write(` FAIL ${c.name}\n`); + throw err; + } + passed += 1; + process.stdout.write(` ok ${c.name}\n`); +} +process.stdout.write(`\n${passed} assertions passed.\n`); diff --git a/tools/release/docker-push.ts b/tools/release/docker-push.ts index 8a7a253a2..c5b9b7509 100644 --- a/tools/release/docker-push.ts +++ b/tools/release/docker-push.ts @@ -31,12 +31,12 @@ import { parseDryRun, run } from './shell.ts'; type Platform = 'linux/amd64' | 'linux/arm64'; /** Map platform to the arch-suffix used in the per-arch tag. */ -const archSuffixFor = (p: Platform): 'amd64' | 'arm64' => { +export const archSuffixFor = (p: Platform): 'amd64' | 'arm64' => { return p === 'linux/amd64' ? 'amd64' : 'arm64'; }; /** CLI options. */ -type Options = { +export type Options = { version: string; platform: Platform | undefined; manifest: boolean; @@ -45,7 +45,7 @@ type Options = { }; /** Parse argv into strongly-typed `Options`. */ -const parseOptions = (argv: string[]): Options => { +export const parseOptions = (argv: string[]): Options => { const { dryRun, rest } = parseDryRun(argv); const { values } = parseArgs({ args: rest, diff --git a/tools/release/gh-release-create.test.ts b/tools/release/gh-release-create.test.ts new file mode 100644 index 000000000..8cda1d53e --- /dev/null +++ b/tools/release/gh-release-create.test.ts @@ -0,0 +1,168 @@ +#!/usr/bin/env -S npx tsx +/** + * Unit tests for the pure helpers exported by `gh-release-create.ts`. + * Exercises `parseOptions`, `collectAssets`, and `renderReleaseNotes`. + * The `gh release create` shell-out is out of scope for a pure-unit + * suite. Run with: + * + * tsx tools/release/gh-release-create.test.ts + */ + +import { strict as assert } from 'node:assert'; +import { mkdtempSync, writeFileSync, mkdirSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { + collectAssets, + parseOptions, + renderReleaseNotes, +} from './gh-release-create.ts'; + +const cases: Array<{ name: string; fn: () => void }> = []; +const test = (name: string, fn: () => void): void => { + cases.push({ name, fn }); +}; + +// ─── parseOptions ─────────────────────────────────────────────── +test('parseOptions: happy path — every field populated', () => { + const o = parseOptions([ + '--version', '7.0.0-dev.42', + '--repo', 'me/proj', + '--assets', '/tmp/nupkg', + '--assets', '/tmp/sbom', + '--docker-image', 'foo/bar:1', + ]); + assert.equal(o.version, '7.0.0-dev.42'); + assert.equal(o.repo, 'me/proj'); + assert.deepEqual(o.assetDirs, ['/tmp/nupkg', '/tmp/sbom']); + assert.equal(o.dockerImage, 'foo/bar:1'); + assert.equal(o.dryRun, false); +}); + +test('parseOptions: defaults — repo, assetDirs, dockerImage undefined', () => { + const o = parseOptions(['--version', '1.0.0']); + assert.equal(o.repo, 'TrakHound/MTConnect.NET'); + assert.equal(o.dockerImage, undefined); + // assetDirs default to build/output/nupkg + build/output/sbom under + // repo root; assert shape rather than exact paths (repo-root-dependent). + assert.equal(o.assetDirs.length, 2); + assert.ok(o.assetDirs[0]!.endsWith('/build/output/nupkg')); + assert.ok(o.assetDirs[1]!.endsWith('/build/output/sbom')); +}); + +test('parseOptions: --dry-run consumed', () => { + const o = parseOptions(['--dry-run', '--version', '1.0.0']); + assert.equal(o.dryRun, true); +}); + +test('parseOptions: missing --version throws', () => { + assert.throws(() => parseOptions([]), /--version is required/); +}); + +// ─── collectAssets ────────────────────────────────────────────── +test('collectAssets: empty input list → empty output', () => { + assert.deepEqual(collectAssets([]), []); +}); + +test('collectAssets: missing dir → skipped with stderr warning, not thrown', () => { + // capture stderr — the function must emit a `skipping missing dir` + // line and continue, not throw. + const original = process.stderr.write.bind(process.stderr); + let buf = ''; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (process.stderr as any).write = (chunk: string | Uint8Array): boolean => { + buf += typeof chunk === 'string' ? chunk : Buffer.from(chunk).toString('utf8'); + return true; + }; + try { + const r = collectAssets(['/definitely/does/not/exist/gh-release-test']); + assert.deepEqual(r, []); + assert.match(buf, /skipping missing dir/); + } finally { + (process.stderr as unknown as { write: typeof original }).write = original; + } +}); + +test('collectAssets: enumerates files, skips dotfiles', () => { + const dir = mkdtempSync(join(tmpdir(), 'gh-release-collect-')); + try { + writeFileSync(join(dir, 'a.nupkg'), 'x'); + writeFileSync(join(dir, 'b.spdx.json'), 'x'); + writeFileSync(join(dir, '.hidden'), 'x'); + mkdirSync(join(dir, '.git')); + const r = collectAssets([dir]); + const names = r.map((p) => p.split('/').pop()).sort(); + assert.deepEqual(names, ['a.nupkg', 'b.spdx.json']); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + +test('collectAssets: aggregates across multiple dirs', () => { + const dirA = mkdtempSync(join(tmpdir(), 'gh-release-collect-a-')); + const dirB = mkdtempSync(join(tmpdir(), 'gh-release-collect-b-')); + try { + writeFileSync(join(dirA, 'one.nupkg'), 'x'); + writeFileSync(join(dirB, 'two.spdx.json'), 'x'); + const r = collectAssets([dirA, dirB]); + assert.equal(r.length, 2); + assert.ok(r.some((p) => p.endsWith('one.nupkg'))); + assert.ok(r.some((p) => p.endsWith('two.spdx.json'))); + } finally { + rmSync(dirA, { recursive: true, force: true }); + rmSync(dirB, { recursive: true, force: true }); + } +}); + +// ─── renderReleaseNotes ───────────────────────────────────────── +test('renderReleaseNotes: header includes MTConnect.NET + version', () => { + const notes = renderReleaseNotes('7.0.0-dev.42', [], undefined); + assert.match(notes, /^# MTConnect\.NET 7\.0\.0-dev\.42$/m); +}); + +test('renderReleaseNotes: empty assets renders explicit "no assets" line', () => { + const notes = renderReleaseNotes('1.0.0', [], undefined); + assert.match(notes, /_No assets attached\._/); +}); + +test('renderReleaseNotes: each asset rendered as backtick-quoted basename', () => { + const notes = renderReleaseNotes('1.0.0', [ + '/build/output/nupkg/MTConnect.NET.Common.1.0.0.nupkg', + '/build/output/sbom/manifest.spdx.json', + ], undefined); + assert.match(notes, /- `MTConnect\.NET\.Common\.1\.0\.0\.nupkg`/); + assert.match(notes, /- `manifest\.spdx\.json`/); +}); + +test('renderReleaseNotes: docker section absent when dockerImage undefined', () => { + const notes = renderReleaseNotes('1.0.0', [], undefined); + assert.equal(/## Docker image/.test(notes), false); +}); + +test('renderReleaseNotes: docker section present with pull command when dockerImage set', () => { + const notes = renderReleaseNotes('1.0.0', [], 'trakhound/mtconnect-agent:1.0.0'); + assert.match(notes, /## Docker image/); + assert.match(notes, /docker pull trakhound\/mtconnect-agent:1\.0\.0/); +}); + +test('renderReleaseNotes: opening prose warns not-for-production', () => { + // Docstring contract: "Not intended for production use". Pin so a + // future refactor of the boilerplate does not silently drop the + // pre-release warning. + const notes = renderReleaseNotes('1.0.0', [], undefined); + assert.match(notes, /Not intended for production use/i); +}); + +// ─── main runner ──────────────────────────────────────────────── +let passed = 0; +for (const c of cases) { + try { + c.fn(); + } catch (err) { + process.stderr.write(` FAIL ${c.name}\n`); + throw err; + } + passed += 1; + process.stdout.write(` ok ${c.name}\n`); +} +process.stdout.write(`\n${passed} assertions passed.\n`); diff --git a/tools/release/gh-release-create.ts b/tools/release/gh-release-create.ts index db366d8ae..8133c0914 100644 --- a/tools/release/gh-release-create.ts +++ b/tools/release/gh-release-create.ts @@ -34,7 +34,7 @@ import { parseDryRun, run } from './shell.ts'; const repoRoot = resolve(new URL('../../', import.meta.url).pathname); /** CLI options. */ -type Options = { +export type Options = { version: string; repo: string; assetDirs: string[]; @@ -46,7 +46,7 @@ type Options = { * accumulate into a list; missing dirs are skipped with a warning * (a workflow may pass both `nupkg/` and `sbom/` even when only one * step ran). */ -const parseOptions = (argv: string[]): Options => { +export const parseOptions = (argv: string[]): Options => { const { dryRun, rest } = parseDryRun(argv); const { values } = parseArgs({ args: rest, @@ -76,7 +76,7 @@ const parseOptions = (argv: string[]): Options => { /** Enumerate assets across the requested directories, returning * absolute paths. Recurses one level so `sbom/*.spdx.json` and * `nupkg/*.nupkg` are both picked up without special-casing. */ -const collectAssets = (dirs: string[]): string[] => { +export const collectAssets = (dirs: string[]): string[] => { const files: string[] = []; for (const dir of dirs) { if (!existsSync(dir)) { @@ -96,7 +96,7 @@ const collectAssets = (dirs: string[]): string[] => { * human-readable manifest of every attached asset and the docker * image reference. Kept plain-markdown so the GitHub release page * renders it without extension conversions. */ -const renderReleaseNotes = ( +export const renderReleaseNotes = ( version: string, assets: string[], dockerImage: string | undefined, diff --git a/tools/release/nuget-push.test.ts b/tools/release/nuget-push.test.ts new file mode 100644 index 000000000..4f212b091 --- /dev/null +++ b/tools/release/nuget-push.test.ts @@ -0,0 +1,106 @@ +#!/usr/bin/env -S npx tsx +/** + * Unit tests for the pure helpers exported by `nuget-push.ts`. The + * `dotnet nuget push` shell-out is out of scope for a pure-unit + * suite. Run with: + * + * tsx tools/release/nuget-push.test.ts + */ + +import { strict as assert } from 'node:assert'; +import { parseOptions } from './nuget-push.ts'; + +const cases: Array<{ name: string; fn: () => void }> = []; +const test = (name: string, fn: () => void): void => { + cases.push({ name, fn }); +}; + +// ─── parseOptions ─────────────────────────────────────────────── +test('parseOptions: happy path — flags override defaults', () => { + const o = parseOptions([ + '--input', '/tmp/nupkg', + '--source', 'https://my.feed/index.json', + '--api-key', 'topsecret', + ]); + assert.equal(o.input, '/tmp/nupkg'); + assert.equal(o.source, 'https://my.feed/index.json'); + assert.equal(o.apiKey, 'topsecret'); + assert.equal(o.dryRun, false); +}); + +test('parseOptions: --input defaults to /build/output/nupkg', () => { + // Wipe NUGET_API_KEY so the apiKey default resolution stays inert. + const prev = process.env.NUGET_API_KEY; + delete process.env.NUGET_API_KEY; + try { + const o = parseOptions([]); + assert.ok(o.input.endsWith('/build/output/nupkg'), o.input); + } finally { + if (prev !== undefined) process.env.NUGET_API_KEY = prev; + } +}); + +test('parseOptions: --source defaults to nuget.org v3 index', () => { + const prev = process.env.NUGET_API_KEY; + delete process.env.NUGET_API_KEY; + try { + const o = parseOptions([]); + assert.equal(o.source, 'https://api.nuget.org/v3/index.json'); + } finally { + if (prev !== undefined) process.env.NUGET_API_KEY = prev; + } +}); + +test('parseOptions: --api-key falls back to NUGET_API_KEY env var', () => { + const prev = process.env.NUGET_API_KEY; + process.env.NUGET_API_KEY = 'from-env'; + try { + const o = parseOptions([]); + assert.equal(o.apiKey, 'from-env'); + } finally { + if (prev === undefined) delete process.env.NUGET_API_KEY; + else process.env.NUGET_API_KEY = prev; + } +}); + +test('parseOptions: explicit --api-key beats NUGET_API_KEY env var', () => { + const prev = process.env.NUGET_API_KEY; + process.env.NUGET_API_KEY = 'from-env'; + try { + const o = parseOptions(['--api-key', 'from-flag']); + assert.equal(o.apiKey, 'from-flag'); + } finally { + if (prev === undefined) delete process.env.NUGET_API_KEY; + else process.env.NUGET_API_KEY = prev; + } +}); + +test('parseOptions: --api-key absent + env unset → undefined', () => { + const prev = process.env.NUGET_API_KEY; + delete process.env.NUGET_API_KEY; + try { + const o = parseOptions([]); + assert.equal(o.apiKey, undefined); + } finally { + if (prev !== undefined) process.env.NUGET_API_KEY = prev; + } +}); + +test('parseOptions: --dry-run consumed', () => { + const o = parseOptions(['--dry-run']); + assert.equal(o.dryRun, true); +}); + +// ─── main runner ──────────────────────────────────────────────── +let passed = 0; +for (const c of cases) { + try { + c.fn(); + } catch (err) { + process.stderr.write(` FAIL ${c.name}\n`); + throw err; + } + passed += 1; + process.stdout.write(` ok ${c.name}\n`); +} +process.stdout.write(`\n${passed} assertions passed.\n`); diff --git a/tools/release/nuget-push.ts b/tools/release/nuget-push.ts index df63be6ff..cb98522c9 100644 --- a/tools/release/nuget-push.ts +++ b/tools/release/nuget-push.ts @@ -24,7 +24,7 @@ import { optionalEnv, parseDryRun, run } from './shell.ts'; const repoRoot = resolve(new URL('../../', import.meta.url).pathname); /** CLI options. */ -type Options = { +export type Options = { input: string; source: string; apiKey: string | undefined; @@ -32,7 +32,7 @@ type Options = { }; /** Parse argv into strongly-typed `Options`. */ -const parseOptions = (argv: string[]): Options => { +export const parseOptions = (argv: string[]): Options => { const { dryRun, rest } = parseDryRun(argv); const { values } = parseArgs({ args: rest, diff --git a/tools/release/pack.test.ts b/tools/release/pack.test.ts new file mode 100644 index 000000000..15f813072 --- /dev/null +++ b/tools/release/pack.test.ts @@ -0,0 +1,57 @@ +#!/usr/bin/env -S npx tsx +/** + * Unit tests for the pure helpers exported by `pack.ts`. The `dotnet + * pack` shell-out is out of scope for a pure-unit suite. Run with: + * + * tsx tools/release/pack.test.ts + */ + +import { strict as assert } from 'node:assert'; +import { parseOptions } from './pack.ts'; + +const cases: Array<{ name: string; fn: () => void }> = []; +const test = (name: string, fn: () => void): void => { + cases.push({ name, fn }); +}; + +// ─── parseOptions ─────────────────────────────────────────────── +test('parseOptions: happy path — every field populated', () => { + const o = parseOptions(['--version', '7.0.0-dev.42', '--output', '/tmp/out']); + assert.equal(o.version, '7.0.0-dev.42'); + assert.equal(o.output, '/tmp/out'); + assert.equal(o.dryRun, false); +}); + +test('parseOptions: --output defaults to /build/output/nupkg', () => { + const o = parseOptions(['--version', '1.0.0']); + assert.ok(o.output.endsWith('/build/output/nupkg'), o.output); +}); + +test('parseOptions: --dry-run consumed', () => { + const o = parseOptions(['--dry-run', '--version', '1.0.0']); + assert.equal(o.dryRun, true); + assert.equal(o.version, '1.0.0'); +}); + +test('parseOptions: missing --version throws with example hint', () => { + // Docstring: "Fails fast on missing `--version`". Also assert the + // error hint text so the CLI ergonomics do not silently drift. + assert.throws( + () => parseOptions([]), + /--version is required.*7\.0\.0-dev\.42/, + ); +}); + +// ─── main runner ──────────────────────────────────────────────── +let passed = 0; +for (const c of cases) { + try { + c.fn(); + } catch (err) { + process.stderr.write(` FAIL ${c.name}\n`); + throw err; + } + passed += 1; + process.stdout.write(` ok ${c.name}\n`); +} +process.stdout.write(`\n${passed} assertions passed.\n`); diff --git a/tools/release/pack.ts b/tools/release/pack.ts index 457b0fc41..25ca063cf 100644 --- a/tools/release/pack.ts +++ b/tools/release/pack.ts @@ -29,7 +29,7 @@ const repoRoot = resolve(new URL('../../', import.meta.url).pathname); /** CLI options parsed via `node:util.parseArgs`. `output` defaults to * `/build/output/nupkg` so per-version outputs are colocated * with the existing Builder layout. */ -type Options = { +export type Options = { version: string; output: string; dryRun: boolean; @@ -37,7 +37,7 @@ type Options = { /** Parse argv into strongly-typed `Options`. Fails fast on missing * `--version`; no other flag is required. */ -const parseOptions = (argv: string[]): Options => { +export const parseOptions = (argv: string[]): Options => { const { dryRun, rest } = parseDryRun(argv); const { values } = parseArgs({ args: rest, diff --git a/tools/release/sbom.test.ts b/tools/release/sbom.test.ts new file mode 100644 index 000000000..33dc3bc77 --- /dev/null +++ b/tools/release/sbom.test.ts @@ -0,0 +1,83 @@ +#!/usr/bin/env -S npx tsx +/** + * Unit tests for the pure helpers exported by `sbom.ts`. The + * `dotnet sbom-tool` and `docker scout sbom` shell-outs are out of + * scope for a pure-unit suite. Run with: + * + * tsx tools/release/sbom.test.ts + */ + +import { strict as assert } from 'node:assert'; +import { parseOptions } from './sbom.ts'; + +const cases: Array<{ name: string; fn: () => void }> = []; +const test = (name: string, fn: () => void): void => { + cases.push({ name, fn }); +}; + +// ─── parseOptions: mode selection ─────────────────────────────── +test('parseOptions: --nuget selects nuget mode, dockerImage undefined', () => { + const o = parseOptions(['--nuget']); + assert.equal(o.mode, 'nuget'); + assert.equal(o.dockerImage, undefined); +}); + +test('parseOptions: --docker selects docker mode', () => { + const o = parseOptions(['--docker', 'trakhound/mtconnect-agent:1.0.0']); + assert.equal(o.mode, 'docker'); + assert.equal(o.dockerImage, 'trakhound/mtconnect-agent:1.0.0'); +}); + +test('parseOptions: neither --nuget nor --docker throws', () => { + assert.throws( + () => parseOptions([]), + /Either --nuget or --docker is required/, + ); +}); + +test('parseOptions: --nuget and --docker are mutually exclusive', () => { + assert.throws( + () => parseOptions(['--nuget', '--docker', 'img:1']), + /mutually exclusive/, + ); +}); + +// ─── parseOptions: --input / --output defaults ────────────────── +test('parseOptions: --input defaults to /build/output/nupkg', () => { + const o = parseOptions(['--nuget']); + assert.ok(o.input.endsWith('/build/output/nupkg'), o.input); +}); + +test('parseOptions: --output defaults to /build/output/sbom', () => { + const o = parseOptions(['--nuget']); + assert.ok(o.output.endsWith('/build/output/sbom'), o.output); +}); + +test('parseOptions: --input override honoured', () => { + const o = parseOptions(['--nuget', '--input', '/tmp/nupkg']); + assert.equal(o.input, '/tmp/nupkg'); +}); + +test('parseOptions: --output override honoured', () => { + const o = parseOptions(['--nuget', '--output', '/tmp/sbom']); + assert.equal(o.output, '/tmp/sbom'); +}); + +test('parseOptions: --dry-run consumed', () => { + const o = parseOptions(['--dry-run', '--nuget']); + assert.equal(o.dryRun, true); +}); + +// ─── main runner ──────────────────────────────────────────────── +let passed = 0; +for (const c of cases) { + try { + c.fn(); + } catch (err) { + process.stderr.write(` FAIL ${c.name}\n`); + throw err; + } + passed += 1; + process.stdout.write(` ok ${c.name}\n`); +} +process.stdout.write(`\n${passed} assertions passed.\n`); diff --git a/tools/release/sbom.ts b/tools/release/sbom.ts index 0c9dcf91c..2f8f35f58 100644 --- a/tools/release/sbom.ts +++ b/tools/release/sbom.ts @@ -28,7 +28,7 @@ const repoRoot = resolve(new URL('../../', import.meta.url).pathname); /** CLI options — `nuget` and `docker` are mutually exclusive top-level * modes. Exactly one must be provided. */ -type Options = { +export type Options = { mode: 'nuget' | 'docker'; input: string; output: string; @@ -37,7 +37,7 @@ type Options = { }; /** Parse argv into strongly-typed `Options`. */ -const parseOptions = (argv: string[]): Options => { +export const parseOptions = (argv: string[]): Options => { const { dryRun, rest } = parseDryRun(argv); const { values } = parseArgs({ args: rest, diff --git a/tools/run-tests.ts b/tools/run-tests.ts new file mode 100644 index 000000000..957ae5900 --- /dev/null +++ b/tools/run-tests.ts @@ -0,0 +1,71 @@ +#!/usr/bin/env -S npx tsx +/** + * Discover and run every `*.test.ts` file under `tools/`. Each test + * file is spawned as its own `tsx` process so its top-level state does + * not leak into siblings (`process.env` mutations, `process.stdout` + * patches, etc.). Exit code is 0 iff every file exits 0. + * + * Run with: + * npm test (via the `test` script in package.json) + * npx tsx tools/run-tests.ts + * + * Kept small and dependency-free — no runner (Jest/Vitest/node:test) + * because the in-house `test(name, fn)` shim in each `*.test.ts` file + * paid the same cost with zero extra config to maintain. + */ + +import { spawn } from 'node:child_process'; +import { readdirSync, statSync } from 'node:fs'; +import { resolve } from 'node:path'; + +const here = resolve(new URL('.', import.meta.url).pathname); + +/** Recursive walk that yields absolute paths of every file ending in + * `.test.ts` under `dir`. `node_modules` is skipped. */ +const walk = (dir: string): string[] => { + const out: string[] = []; + for (const name of readdirSync(dir)) { + if (name === 'node_modules' || name.startsWith('.')) continue; + const path = resolve(dir, name); + const st = statSync(path); + if (st.isDirectory()) { + out.push(...walk(path)); + } else if (name.endsWith('.test.ts')) { + out.push(path); + } + } + return out; +}; + +const files = walk(here).sort(); +if (files.length === 0) { + process.stdout.write('no *.test.ts files found under tools/\n'); + process.exit(0); +} + +const runOne = (file: string): Promise => + new Promise((resolvePromise) => { + const rel = file.slice(here.length + 1); + process.stdout.write(`\n── ${rel} ──────────────────────────────────\n`); + const child = spawn('npx', ['tsx', file], { stdio: 'inherit' }); + child.on('exit', (code) => resolvePromise(code ?? 1)); + child.on('error', (err) => { + process.stderr.write(`spawn error for ${rel}: ${err.message}\n`); + resolvePromise(1); + }); + }); + +const main = async (): Promise => { + let failed = 0; + for (const f of files) { + const code = await runOne(f); + if (code !== 0) failed += 1; + } + process.stdout.write( + `\n── summary ────────────────────────────────────\n` + + `${files.length} test file(s), ${failed} failure(s)\n`, + ); + process.exit(failed === 0 ? 0 : 1); +}; + +main(); From 51c03f7e8c4c2cc478dd9c8eb20828084ead3dbb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Otto=20Boly=C3=B3s?= Date: Wed, 19 Aug 2026 16:07:42 +0200 Subject: [PATCH 05/15] fix(ci): pin setup-dotnet + renovatebot to verified upstream SHAs Replaces the placeholder v4 SHA on actions/setup-dotnet with the SHA of the v4.3.1 tag verified via `gh api repos/actions/setup-dotnet/git/ refs/tags/v4.3.1`, and updates renovatebot/github-action from an unverified SHA to the SHA of tag v43.0.7 verified via `gh api repos/renovatebot/github-action/git/refs/tags/v43.0.7`. Closes findings F-SEC-001 and F-SEC-002 from the PR #225 review. --- .github/workflows/deps-update.yml | 4 ++-- .github/workflows/release.yml | 6 +++--- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/.github/workflows/deps-update.yml b/.github/workflows/deps-update.yml index 6233f1929..8410a9cca 100644 --- a/.github/workflows/deps-update.yml +++ b/.github/workflows/deps-update.yml @@ -60,7 +60,7 @@ jobs: node-version: '20' - name: Setup .NET 8.0 + 9.0 - uses: actions/setup-dotnet@a893c5db93b64e8908c5aeee54cb0c0f2d519d1e # v4 + uses: actions/setup-dotnet@67a3573c9a986a3f9c594539f4ab511d57bb3ce9 # v4.3.1 with: dotnet-version: | 8.0.x @@ -92,7 +92,7 @@ jobs: # 1) GitHub Actions plugin versions. # ------------------------------------------------------------ - name: Bump GitHub Actions pins - uses: renovatebot/github-action@a11a708142f8db3d33d7bfa6707a91b0e1eee06f # v43.0.7 + uses: renovatebot/github-action@85b17ebd5abf43d1c34c01bd4c8dbb8d45bbc2c7 # v43.0.7 with: configurationFile: .github/renovate-actions-only.json token: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index f514571ca..fe669a816 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -86,7 +86,7 @@ jobs: uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 - name: Setup .NET 8.0 + 9.0 - uses: actions/setup-dotnet@a893c5db93b64e8908c5aeee54cb0c0f2d519d1e # v4 + uses: actions/setup-dotnet@67a3573c9a986a3f9c594539f4ab511d57bb3ce9 # v4.3.1 with: dotnet-version: | 8.0.x @@ -257,7 +257,7 @@ jobs: uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 - name: Setup .NET 8.0 + 9.0 - uses: actions/setup-dotnet@a893c5db93b64e8908c5aeee54cb0c0f2d519d1e # v4 + uses: actions/setup-dotnet@67a3573c9a986a3f9c594539f4ab511d57bb3ce9 # v4.3.1 with: dotnet-version: | 8.0.x @@ -380,7 +380,7 @@ jobs: uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 - name: Setup .NET 8.0 + 9.0 - uses: actions/setup-dotnet@a893c5db93b64e8908c5aeee54cb0c0f2d519d1e # v4 + uses: actions/setup-dotnet@67a3573c9a986a3f9c594539f4ab511d57bb3ce9 # v4.3.1 with: dotnet-version: | 8.0.x From 2f9ce013f2e11197cb4c8db8be3fbf639366210c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Otto=20Boly=C3=B3s?= Date: Wed, 19 Aug 2026 16:11:01 +0200 Subject: [PATCH 06/15] fix(ci): correct semver-bump boundary off-by-one at the dev-counter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `countCommitsSinceLastDevBoundary` returned `i + 1` at both the stable-cut-marker and dev-tag boundaries, off by one relative to the docstring ("commits BEFORE the marker") and out of step with the `--range` code path which counts commits after the boundary. Change both return sites to `Math.max(1, i)` so the two paths agree on a fixture where the marker sits at HEAD~1 (expected N = 1) and the floor still guards against `-dev.0` when the boundary sits on HEAD. Extends `semver-bump.test.ts` with three git-fixture cases covering the two boundary positions plus the no-boundary fall-through, and reworks the top-of-file algorithm summary (F-DOC-002 + F-DOC-007) so the docstring matches the code and calls out the `applyBump` `none → patch` fall-through that keeps a chore-only range distinct from the last stable tag. Closes findings F-CR-001, F-DOC-002, and F-DOC-007 from the PR #225 review. --- tools/ci/semver-bump.test.ts | 106 +++++++++++++++++++++++++++++++++++ tools/ci/semver-bump.ts | 33 ++++++----- 2 files changed, 126 insertions(+), 13 deletions(-) diff --git a/tools/ci/semver-bump.test.ts b/tools/ci/semver-bump.test.ts index bf07eb304..96c55d13c 100644 --- a/tools/ci/semver-bump.test.ts +++ b/tools/ci/semver-bump.test.ts @@ -11,10 +11,16 @@ */ import { strict as assert } from 'node:assert'; +import { execSync } from 'node:child_process'; +import { mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; import { aggregateBump, applyBump, bumpKindFor, + commitsInRange, + countCommitsSinceLastDevBoundary, isDevTag, isStableCutMarker, } from './semver-bump.ts'; @@ -268,4 +274,104 @@ test('isDevTag: accepts zero counter', () => { assert.equal(isDevTag('v10.20.30-dev.400'), true); }); +// ─── countCommitsSinceLastDevBoundary (git-fixture) ───────────── +// Builds a throw-away git repo, then chdir's into it so the module's +// bare `git` calls resolve to that repo. Verifies the two counter +// paths (`countCommitsSinceLastDevBoundary` + the `--range`-derived +// `Math.max(1, commits.length)`) agree on a boundary that lives at +// HEAD~1 — the fixture the docstring pins as the reference case. +test('countCommitsSinceLastDevBoundary: boundary at HEAD~1 → N=1 (agrees with --range path)', () => { + const dir = mkdtempSync(join(tmpdir(), 'semver-boundary-')); + const g = (cmd: string): string => + execSync(`git ${cmd}`, { cwd: dir, stdio: ['ignore', 'pipe', 'pipe'] }) + .toString() + .trim(); + const cwd = process.cwd(); + try { + g('init -q -b master'); + g('config user.email test@example'); + g('config user.name test'); + // Suppress developer-machine gpg/signing so `git tag` and + // `git commit` do not prompt for a key inside the sandbox. + g('config commit.gpgsign false'); + g('config tag.gpgsign false'); + g('commit -q --allow-empty -m "initial"'); + g('tag v0.0.0'); + g('commit -q --allow-empty -m "chore(release): publish new stable"'); + const markerSha = g('rev-parse HEAD'); + g('commit -q --allow-empty -m "feat(agent): post-marker one"'); + + process.chdir(dir); + // Boundary path: walk from stable tag until the marker at index 1 + // (HEAD~1). Excludes the boundary itself → N = 1. + assert.equal(countCommitsSinceLastDevBoundary(), 1); + // --range path: pass a range that STARTS at the marker (exclusive + // per git's `A..B` semantics); one commit remains → N = 1. + const rangeCommits = commitsInRange(markerSha, 'HEAD'); + assert.equal(Math.max(1, rangeCommits.length), 1); + } finally { + process.chdir(cwd); + rmSync(dir, { recursive: true, force: true }); + } +}); + +test('countCommitsSinceLastDevBoundary: boundary at HEAD itself → N=1 (floor guard)', () => { + // A stable-cut marker sitting on HEAD would return i = 0 without the + // Math.max(1, i) floor; pin the guard so the next `-dev.N` for that + // commit never collapses to `-dev.0`. + const dir = mkdtempSync(join(tmpdir(), 'semver-boundary-head-')); + const g = (cmd: string): string => + execSync(`git ${cmd}`, { cwd: dir, stdio: ['ignore', 'pipe', 'pipe'] }) + .toString() + .trim(); + const cwd = process.cwd(); + try { + g('init -q -b master'); + g('config user.email test@example'); + g('config user.name test'); + // Suppress developer-machine gpg/signing so `git tag` and + // `git commit` do not prompt for a key inside the sandbox. + g('config commit.gpgsign false'); + g('config tag.gpgsign false'); + g('commit -q --allow-empty -m "initial"'); + g('tag v0.0.0'); + g('commit -q --allow-empty -m "chore(release): publish new stable"'); + process.chdir(dir); + assert.equal(countCommitsSinceLastDevBoundary(), 1); + } finally { + process.chdir(cwd); + rmSync(dir, { recursive: true, force: true }); + } +}); + +test('countCommitsSinceLastDevBoundary: no boundary, three commits since stable → N=3', () => { + // Fall-through arm: no marker, no dev tag; N equals the plain commit + // count. Pins the trailing `Math.max(1, commits.length)`. + const dir = mkdtempSync(join(tmpdir(), 'semver-noboundary-')); + const g = (cmd: string): string => + execSync(`git ${cmd}`, { cwd: dir, stdio: ['ignore', 'pipe', 'pipe'] }) + .toString() + .trim(); + const cwd = process.cwd(); + try { + g('init -q -b master'); + g('config user.email test@example'); + g('config user.name test'); + // Suppress developer-machine gpg/signing so `git tag` and + // `git commit` do not prompt for a key inside the sandbox. + g('config commit.gpgsign false'); + g('config tag.gpgsign false'); + g('commit -q --allow-empty -m "initial"'); + g('tag v0.0.0'); + g('commit -q --allow-empty -m "feat(agent): one"'); + g('commit -q --allow-empty -m "fix(agent): two"'); + g('commit -q --allow-empty -m "docs(agent): three"'); + process.chdir(dir); + assert.equal(countCommitsSinceLastDevBoundary(), 3); + } finally { + process.chdir(cwd); + rmSync(dir, { recursive: true, force: true }); + } +}); + process.stdout.write(`\n${passed} assertions passed.\n`); diff --git a/tools/ci/semver-bump.ts b/tools/ci/semver-bump.ts index bc4eaeaf4..a8564a7f5 100644 --- a/tools/ci/semver-bump.ts +++ b/tools/ci/semver-bump.ts @@ -18,7 +18,10 @@ * under a green `pre-merge` gate, but the algorithm stays * permissive so a partially-migrated history still resolves). * The largest bump wins — a `BREAKING CHANGE` anywhere in the - * range beats every `feat` and `fix`. + * range beats every `feat` and `fix`. An all-`none` range still + * lands a patch bump via `applyBump`'s `none → patch` fall-through + * so the emitted version is distinct from the last stable tag + * (avoids a `-dev.N` collision on a chore-only range). * 4. Count the commits reachable from `HEAD` back to the most * recent commit that either * (a) is the stable-cut marker @@ -36,9 +39,10 @@ * * When `--range ..` is passed, the commit range is taken * verbatim instead of being derived from the most recent stable tag. - * The pre-release counter is then just the number of commits in the - * range plus one (matches the tag-derived case when the range starts - * at the stable cut). Used by the unit-test hook in `test.ts`. + * The pre-release counter is then the number of commits in the range, + * floored at one so an empty range still produces a distinct dev + * version (matches the tag-derived case when the range starts at the + * stable cut). Used by the unit-test hook in `test.ts`. */ import { spawnSync } from 'node:child_process'; @@ -196,24 +200,27 @@ export const commitsInRange = ( }; /** Count how many commits reachable from HEAD (walking parents) come - * before we hit either the stable-cut marker or a `vX.Y.Z-dev.N` + * BEFORE we hit either the stable-cut marker or a `vX.Y.Z-dev.N` * tag. Returns the count of commits that are still on the "current" - * dev cycle, i.e. the `N` for the next `-dev.`. */ + * dev cycle, i.e. the `N` for the next `-dev.`. The + * boundary commit itself is excluded — it belongs to the prior + * cycle — and the counter is floored at one so a boundary sitting on + * HEAD still produces a distinct dev version. */ export const countCommitsSinceLastDevBoundary = (): number => { // Fastest path: if HEAD or an ancestor is tagged with a stable version, - // the count is (commits since that tag) + 1. + // walk the range and stop at the first stable-cut marker or dev tag. const stable = lastStableTag(); const commits = commitsInRange(stable); - // Now scan those commits for the stable-cut marker or a dev tag on - // the commit itself. If we find one, N = (commits between it and - // HEAD) + 1. + // `commits` is ordered newest-first (git log default). Index `i` is + // the number of commits between HEAD and `commits[i]` exclusive — + // exactly the counter value once we exclude the boundary itself. for (let i = 0; i < commits.length; i++) { const c = commits[i]!; if (isStableCutMarker(c.subject)) { - return i + 1; + return Math.max(1, i); } - // Any dev tag on this commit resets the counter to i + 1. + // Any dev tag on this commit ends the current cycle at index `i`. const tagsRaw = spawnSync( 'git', ['tag', '--points-at', c.sha, '--list', 'v*-dev.*'], @@ -221,7 +228,7 @@ export const countCommitsSinceLastDevBoundary = (): number => { ); if (tagsRaw.status === 0) { const tags = tagsRaw.stdout.split('\n').map((t) => t.trim()).filter((t) => isDevTag(t)); - if (tags.length > 0) return i + 1; + if (tags.length > 0) return Math.max(1, i); } } // No stable-cut marker and no dev-tag boundary found — the count From 5314b0ea1241638682d9477c3448ded26ec1c4c9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Otto=20Boly=C3=B3s?= Date: Wed, 19 Aug 2026 16:14:08 +0200 Subject: [PATCH 07/15] ci(deps): split weekly update into per-package renovate + bulk PR `renovatebot/github-action` opens its own PRs per package on `renovate/*` branches, so the "one deps PR per week" invariant only ever held for the two ecosystems this workflow drives directly (npm + NuGet). The no-op "Handled by the Renovate step above" step is gone, the bulk-PR branch is renamed to `chore/deps-weekly-npm-nuget` to disambiguate it from Renovate's own branches, and the header comment + docs page now describe the two PR shapes accurately. npm bumps now pass `--cooldown "$MIN_AGE_DAYS"` (added in ncu v18), which pins the same 7-day supply-chain quarantine Renovate enforces on GH Actions + Dockerfile bases. NuGet has no equivalent (`dotnet-outdated` lacks any age filter) and the workflow header, step comment, and docs page all state this explicitly; a proper NuGet quarantine is tracked as a follow-up. Also switches the doc from Renovate's non-existent `docker` manager name to the actual `dockerfile` manager (F-DOC-005). Closes findings F-CR-002, F-CR-003, F-DOC-001, F-DOC-005, F-IMP-002, and F-SEC-005 from the PR #225 review. --- .github/workflows/deps-update.yml | 106 +++++++++++++++++------------- docs/development/deps-update.md | 59 ++++++++++++----- 2 files changed, 104 insertions(+), 61 deletions(-) diff --git a/.github/workflows/deps-update.yml b/.github/workflows/deps-update.yml index 8410a9cca..f1a59c9b6 100644 --- a/.github/workflows/deps-update.yml +++ b/.github/workflows/deps-update.yml @@ -1,22 +1,31 @@ name: deps-update # ------------------------------------------------------------------ -# Weekly bulk dependency update. Fires every Saturday at 02:00 UTC -# (avoids the working-hours release window and clears reviewer -# attention over the weekend). Bumps four ecosystems in one PR: +# Weekly dependency update. Fires every Saturday at 02:00 UTC (avoids +# the working-hours release window and clears reviewer attention over +# the weekend). Bumps four ecosystems, split across two PR shapes: # -# - GitHub Actions plugin versions in `.github/workflows/*.yml`; -# - Docker base images in every Dockerfile; -# - npm packages under `docs/`; -# - NuGet packages across every `.csproj`. +# - GitHub Actions plugin versions in `.github/workflows/*.yml` — +# Renovate opens one PR per package; +# - Docker base images in every Dockerfile — Renovate opens one PR +# per base image; +# - npm packages under `docs/` — this workflow opens a single +# `chore/deps-weekly-npm-nuget` PR; +# - NuGet packages across every `.csproj` — same PR as npm. # -# Every candidate release is filtered by a >=7-day quarantine — no -# version younger than a week is accepted, so a poisoned publish that -# gets yanked within the standard OSS response window is excluded -# automatically. +# Actions + Dockerfile bases (via Renovate) and npm packages (via +# `npm-check-updates --cooldown`) enforce a >=7-day supply-chain +# quarantine — no version younger than a week is accepted, so a +# poisoned publish that gets yanked within the standard OSS response +# window is excluded automatically. NuGet does NOT enforce the +# quarantine: `dotnet-outdated` has no built-in age filter, so NuGet +# bumps rely on downstream CI + review to catch a hot-published bad +# release. Adding a NuGet quarantine is tracked as a follow-up. # -# A prior deps-update PR that is still open when the workflow re-fires -# is closed as superseded; only one deps PR is ever open at once. +# A prior npm+NuGet PR that is still open when the workflow re-fires +# is closed as superseded — only one bulk PR from this workflow is +# ever open at once. Renovate's per-package PRs live under their own +# `renovate/*` branches and follow Renovate's usual open/close rules. # ------------------------------------------------------------------ on: @@ -38,7 +47,10 @@ env: # Minimum age (days) a release must have before it is eligible for # inclusion. Increase to widen the quarantine. MIN_AGE_DAYS: '7' - BRANCH_NAME: chore/deps-weekly-update + # Branch that holds the npm + NuGet bulk bump. Renovate opens its + # own per-package branches under `renovate/*` for GH Actions + + # Dockerfile bases and manages them independently. + BRANCH_NAME: chore/deps-weekly-npm-nuget jobs: bump: @@ -66,15 +78,16 @@ jobs: 8.0.x 9.0.x - - name: Close prior deps PR if still open + - name: Close prior npm+NuGet deps PR if still open env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} BRANCH_NAME: ${{ env.BRANCH_NAME }} run: | set -euo pipefail - # Enumerate open PRs targeting the deps branch — expected 0 or 1. - # Close each as "superseded"; the fresh branch push below opens - # the replacement. + # Enumerate open PRs targeting this workflow's own bulk branch — + # expected 0 or 1. Renovate's per-package PRs live on their own + # `renovate/*` branches and are NOT touched here. Close each as + # "superseded"; the fresh branch push below opens the replacement. for n in $(gh pr list --state open --head "$BRANCH_NAME" --json number --jq '.[].number'); do gh pr close "$n" --comment "Superseded by the next weekly deps run." done @@ -89,43 +102,46 @@ jobs: git checkout -B "$BRANCH_NAME" origin/master # ------------------------------------------------------------ - # 1) GitHub Actions plugin versions. + # 1) GitHub Actions plugin versions AND Dockerfile base images. + # Renovate handles both managers from the same config file + # and opens one PR per package on its own `renovate/*` + # branches — this step's output is out-of-band from the + # npm + NuGet bulk PR that follows. # ------------------------------------------------------------ - - name: Bump GitHub Actions pins + - name: Bump GitHub Actions + Dockerfile bases (via Renovate) uses: renovatebot/github-action@85b17ebd5abf43d1c34c01bd4c8dbb8d45bbc2c7 # v43.0.7 with: configurationFile: .github/renovate-actions-only.json token: ${{ secrets.GITHUB_TOKEN }} # ------------------------------------------------------------ - # 2) Docker base images. Renovate covers Dockerfile FROM lines - # via its `docker` manager, driven by the same config file. - # ------------------------------------------------------------ - - name: Bump Docker base images (bundled with the Renovate run) - run: 'echo "Handled by the Renovate step above via the docker manager."' - - # ------------------------------------------------------------ - # 3) npm packages under docs/. + # 2) npm packages under docs/. # ------------------------------------------------------------ - name: Bump npm deps under docs/ working-directory: docs + env: + MIN_AGE_DAYS: ${{ env.MIN_AGE_DAYS }} run: | set -euo pipefail - # `npm-check-updates` respects the MIN_AGE_DAYS filter (npm - # publish timestamps are queried via the registry). - npx --yes npm-check-updates@^17 --upgrade --minimal --enginesNode --target minor + # `npm-check-updates --cooldown ` (added in v18) rejects any + # candidate release whose publish timestamp on the npm + # registry is younger than days. Pins npm to the same + # supply-chain quarantine Renovate enforces on GH-Actions + + # Dockerfile bases. + npx --yes npm-check-updates@^18 --upgrade --minimal --enginesNode --target minor --cooldown "$MIN_AGE_DAYS" # Fall back to package-lock refresh so the diff round-trips. npm install --package-lock-only # ------------------------------------------------------------ - # 4) NuGet packages across every .csproj. `dotnet-outdated-tool` + # 3) NuGet packages across every .csproj. `dotnet-outdated-tool` # walks the whole solution and rewrites the version pins in - # place; the >=7-day filter is applied by parsing package - # metadata via `dotnet nuget list source` + `nuget.org` REST. + # place. `dotnet-outdated` has NO built-in age filter, so + # NuGet bumps do NOT participate in the MIN_AGE_DAYS + # quarantine — downstream CI + reviewer eyes are the only + # protection against a hot-published bad release. Adding a + # proper quarantine is tracked as a follow-up. # ------------------------------------------------------------ - name: Bump NuGet package versions - env: - MIN_AGE_DAYS: ${{ env.MIN_AGE_DAYS }} run: | set -euo pipefail dotnet tool install --global dotnet-outdated-tool @@ -133,7 +149,8 @@ jobs: export PATH="$PATH:$HOME/.dotnet/tools" # `--upgrade` rewrites .csproj files in place with the newest # eligible version subject to the pre-release filter (dev - # pre-releases are excluded — stable only). + # pre-releases are excluded — stable only). No age filter is + # applied; see the comment above the step. dotnet outdated --upgrade --pre-release Never MTConnect.NET.sln - name: Commit + push if any changes @@ -146,7 +163,7 @@ jobs: exit 0 fi git add -A - git commit -m "chore(deps): weekly bulk update" + git commit -m "chore(deps): weekly npm+nuget bulk update" git push --set-upstream origin "$BRANCH_NAME" --force-with-lease - name: Open PR @@ -155,16 +172,17 @@ jobs: BRANCH_NAME: ${{ env.BRANCH_NAME }} run: | set -euo pipefail - # `--fill` uses the last commit's subject as the PR title, which - # is exactly `chore(deps): weekly bulk update`. - BODY="Automated weekly bulk update." - BODY="$BODY Every candidate release passed the" - BODY="$BODY ${MIN_AGE_DAYS}-day supply-chain quarantine." + BODY="Automated weekly bulk update for npm (docs/) + NuGet" + BODY="$BODY (all .csproj). GH Actions + Dockerfile bumps ship" + BODY="$BODY as separate per-package Renovate PRs." + BODY="$BODY npm candidates passed the ${MIN_AGE_DAYS}-day" + BODY="$BODY supply-chain quarantine via ncu --cooldown; NuGet" + BODY="$BODY bumps have no age filter (dotnet-outdated lacks one)." BODY="$BODY Auto-merge is enabled — a green CI run merges" BODY="$BODY without maintainer action." if ! gh pr view "$BRANCH_NAME" --json number >/dev/null 2>&1; then gh pr create \ - --title "chore(deps): weekly bulk update" \ + --title "chore(deps): weekly npm+nuget bulk update" \ --body "$BODY" \ --base master \ --head "$BRANCH_NAME" diff --git a/docs/development/deps-update.md b/docs/development/deps-update.md index 4a4ae5864..7c67be2e4 100644 --- a/docs/development/deps-update.md +++ b/docs/development/deps-update.md @@ -1,25 +1,43 @@ # Weekly deps update The `deps-update` workflow (`.github/workflows/deps-update.yml`) fires -every Saturday at 02:00 UTC and opens one PR that bumps every -dependency in four ecosystems. +every Saturday at 02:00 UTC. It bumps every dependency in four +ecosystems, split across two PR shapes. ## Ecosystems covered - GitHub Actions plugin versions in `.github/workflows/*.yml` (via - Renovate's `github-actions` manager). -- Docker base images in every `Dockerfile` (via Renovate's `docker` - manager). -- npm packages under `docs/` (via `npm-check-updates`). -- NuGet packages across every `.csproj` (via `dotnet-outdated-tool`). + Renovate's `github-actions` manager) — one PR per package on a + `renovate/*` branch. +- Docker base images in every `Dockerfile` (via Renovate's + `dockerfile` manager) — one PR per base image on a `renovate/*` + branch. +- npm packages under `docs/` (via `npm-check-updates`) — folded into + the single `chore/deps-weekly-npm-nuget` bulk PR. +- NuGet packages across every `.csproj` (via `dotnet-outdated-tool`) + — folded into the same bulk PR as npm. ## Supply-chain quarantine -Every candidate release is filtered by a minimum-age check — no -version younger than seven days is accepted. The invariant catches -the standard OSS "poisoned publish yanked within a week" response -window. Increase the window by editing `env.MIN_AGE_DAYS` at the top -of the workflow. +Three of the four ecosystems apply a minimum-age filter — no version +younger than `env.MIN_AGE_DAYS` (default seven) days is accepted: + +- **GitHub Actions + Dockerfile bases** — Renovate's + `minimumReleaseAge` config passed through + `.github/renovate-actions-only.json`. +- **npm packages** — `npm-check-updates --cooldown ` (v18+), + which rejects any candidate release whose npm-registry publish + timestamp is younger than the cooldown window. +- **NuGet packages** — **no quarantine**. `dotnet-outdated` has no + built-in age filter, so a hot-published bad NuGet release will land + in the weekly PR unfiltered; downstream CI + reviewer eyes are the + only line of defence. A proper NuGet quarantine is tracked as a + follow-up. + +The invariant catches the standard OSS "poisoned publish yanked +within a week" response window for the three ecosystems that support +it. Increase the window by editing `env.MIN_AGE_DAYS` at the top of +the workflow. ## Auto-merge @@ -27,12 +45,19 @@ The workflow enables auto-merge on the resulting PR (`gh pr merge --auto --squash`). CI must go green for the merge to happen; a red CI keeps the PR open for triage. -## Single-PR invariant +## PR shapes + +Two shapes ship in parallel every week: -If a prior deps PR from the branch `chore/deps-weekly-update` is -still open when the workflow re-fires, it is closed as superseded -before the new branch is pushed. Only one deps PR is ever open at -once — the newest bumps supersede the older ones by construction. +- **Per-package Renovate PRs** for GH Actions + Dockerfile bases — + Renovate opens one PR per package on its own `renovate/*` branch. + Each PR follows Renovate's own open/close/rebase rules; this + workflow does not manage them. +- **One bulk PR** for npm + NuGet on `chore/deps-weekly-npm-nuget`. + If a prior bulk PR is still open when the workflow re-fires, it is + closed as superseded before the new branch is pushed. Only one bulk + PR from this workflow is ever open at once — the newest bumps + supersede the older ones by construction. ## Manual re-run From 9d3f44bf890fa248fc8975acdcf31ed6365e4cd4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Otto=20Boly=C3=B3s?= Date: Wed, 19 Aug 2026 16:16:06 +0200 Subject: [PATCH 08/15] refactor(release): drop noisy buildx flags + swap docker SBOM to syft MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `buildx build --load` on the default docker driver silently strips `--sbom true` and `--provenance mode=max` — the loader only knows how to import a single-platform image manifest, not an image index. Drop both flags from `docker-build.ts` and add a comment explaining why so a future author does not re-add them. The downstream `sbom` job in `release.yml` still emits an SPDX SBOM from the pushed image, so the metadata surface is preserved without the buildx-strip warning. Swaps the docker SBOM path from `docker scout sbom` (required a `docker scout` install on every runner, produced a materially different SPDX shape) to `anchore/sbom-action` (v0.24.0, pinned by SHA verified via `gh api repos/anchore/sbom-action/git/refs/tags/ v0.24.0`). `sbom.ts` still supports docker mode locally, now via syft — the same engine anchore/sbom-action wraps — so `--dry-run` output shape agrees with CI. Docs pages updated to name the new backend. Closes findings F-CR-005 and F-CR-006 from the PR #225 review. --- .github/workflows/release.yml | 18 +++++++++++++---- docs/development/release-pipeline.md | 2 +- docs/development/tools-release.md | 3 ++- tools/release/docker-build.ts | 15 +++++++------- tools/release/sbom.test.ts | 4 ++-- tools/release/sbom.ts | 29 ++++++++++++++-------------- 6 files changed, 41 insertions(+), 30 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index fe669a816..7428ebafe 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -297,10 +297,20 @@ jobs: VERSION: ${{ needs.compute-version.outputs.version }} run: docker pull "trakhound/mtconnect-agent:$VERSION" - - name: Generate Docker SBOM - env: - VERSION: ${{ needs.compute-version.outputs.version }} - run: npx tsx tools/release/sbom.ts --docker "trakhound/mtconnect-agent:$VERSION" + # `anchore/sbom-action` wraps syft, the SBOM engine `sbom.ts` also + # invokes for its local `--dry-run` path — same tool, same SPDX + # shape whether run in CI or by a developer verifying the pipeline + # on a workstation. Writes to `build/output/sbom/`, colocated with + # the .nupkg SBOM the preceding step produced. + - name: Generate Docker SBOM (anchore/syft) + uses: anchore/sbom-action@e22c389904149dbc22b58101806040fa8d37a610 # v0.24.0 + with: + image: trakhound/mtconnect-agent:${{ needs.compute-version.outputs.version }} + format: spdx-json + artifact-name: docker-image.spdx.json + output-file: build/output/sbom/docker-image.spdx.json + upload-artifact: false + upload-release-assets: false - name: Upload SBOMs uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 diff --git a/docs/development/release-pipeline.md b/docs/development/release-pipeline.md index 58e00e87b..afa1b04b4 100644 --- a/docs/development/release-pipeline.md +++ b/docs/development/release-pipeline.md @@ -21,7 +21,7 @@ collapses into the latest push and cancels any in-flight prior run. | `docker-amd64` | `ubuntu-latest` | Native `linux/amd64` image via `docker buildx build`, pushed as `:-amd64`. | | `docker-arm64` | `ubuntu-24.04-arm` | Native `linux/arm64` image, pushed as `:-arm64`. | | `docker-manifest` | `ubuntu-latest` | Merges the two per-arch tags into a single multi-arch tag `:` via `docker buildx imagetools create`. | -| `sbom` | `ubuntu-latest` | SPDX SBOMs — `Microsoft.Sbom.DotNetTool` over the `.nupkg` set + `docker scout sbom` over the merged image. | +| `sbom` | `ubuntu-latest` | SPDX SBOMs — `Microsoft.Sbom.DotNetTool` over the `.nupkg` set + `anchore/sbom-action` (syft) over the merged image. | | `vuln-scan` | `ubuntu-latest` | `aquasecurity/trivy-action` scans the `.nupkg` set and the Docker image; SARIF uploaded to the Security tab. | | `publish-nuget` | `ubuntu-latest` | `dotnet nuget push` every `.nupkg` to nuget.org via `NUGET_API_KEY`. | | `create-gh-release` | `ubuntu-latest` | `gh release create v --prerelease` with SBOMs + `.nupkg`s attached and the Docker image ref in the notes. | diff --git a/docs/development/tools-release.md b/docs/development/tools-release.md index c29e8e358..ad8631196 100644 --- a/docs/development/tools-release.md +++ b/docs/development/tools-release.md @@ -60,7 +60,8 @@ tsx tools/release/docker-push.ts --version 7.0.0-dev.42 --manifest Generates an SPDX SBOM for either the `.nupkg` set (via `Microsoft.Sbom.DotNetTool`) or a specific Docker image (via -`docker scout sbom`). Writes outputs to `build/output/sbom/`. +`syft`, the SBOM engine `anchore/sbom-action` wraps in CI). Writes +outputs to `build/output/sbom/`. ``` tsx tools/release/sbom.ts --nuget --input build/output/nupkg diff --git a/tools/release/docker-build.ts b/tools/release/docker-build.ts index 6be11b699..12030da23 100644 --- a/tools/release/docker-build.ts +++ b/tools/release/docker-build.ts @@ -98,13 +98,14 @@ export const main = async (argv: string[]): Promise => { '--tag', tag, '--load', - // Emit provenance + SBOM metadata inside the OCI image; the - // downstream `sbom.ts` step reads them back out via - // `docker buildx imagetools inspect`. - '--provenance', - 'mode=max', - '--sbom', - 'true', + // NOTE: `--provenance` and `--sbom` are deliberately NOT passed + // here. `buildx build --load` on the default `docker` driver + // silently strips both flags (the docker-in-docker image loader + // only knows how to import a single-platform image manifest, not + // an image index). The downstream `sbom` job in `release.yml` + // generates a fresh SPDX SBOM from the pushed image via + // `sbom.ts`, so the metadata surface is preserved without paying + // the buildx-strip warning noise on every run. repoRoot, ]; await run('docker', args, { dryRun: opts.dryRun, cwd: repoRoot }); diff --git a/tools/release/sbom.test.ts b/tools/release/sbom.test.ts index 33dc3bc77..bce2533e1 100644 --- a/tools/release/sbom.test.ts +++ b/tools/release/sbom.test.ts @@ -1,8 +1,8 @@ #!/usr/bin/env -S npx tsx /** * Unit tests for the pure helpers exported by `sbom.ts`. The - * `dotnet sbom-tool` and `docker scout sbom` shell-outs are out of - * scope for a pure-unit suite. Run with: + * `dotnet sbom-tool` and `syft` shell-outs are out of scope for a + * pure-unit suite. Run with: * * tsx tools/release/sbom.test.ts */ diff --git a/tools/release/sbom.ts b/tools/release/sbom.ts index 2f8f35f58..663ebf207 100644 --- a/tools/release/sbom.ts +++ b/tools/release/sbom.ts @@ -9,9 +9,15 @@ * as a global dotnet tool (`dotnet tool install --global * Microsoft.Sbom.DotNetTool`) before this script runs; the * release workflow's `sbom` job does that in a preceding step. - * 2. `--docker ` — invokes `docker scout sbom - * --format spdx-json` against a locally-built image and writes - * the result to `/docker--.spdx.json`. + * 2. `--docker ` — invokes `syft -o + * spdx-json=/…`. Syft is the SBOM engine that Anchore + * ship inside the `anchore/sbom-action` GitHub Action the + * workflow uses for its CI path — running syft directly from + * this script keeps the local `--dry-run` output shape aligned + * with what CI produces. `docker scout sbom` was the previous + * backend and is no longer used: it required a `docker scout` + * install on the runner and produced a materially different + * SPDX shape from the anchore/syft baseline. * * Usage: * tsx tools/release/sbom.ts --nuget [--input ] [--output ] [--dry-run] @@ -96,21 +102,14 @@ export const main = async (argv: string[]): Promise => { } // Docker mode — the image should already be present locally (built - // by `docker-build.ts`). `docker scout sbom` streams JSON on stdout; - // capture with the shell redirect the workflow wires up. + // by `docker-build.ts` and pulled by the workflow). Syft scans the + // image layers and writes SPDX-JSON straight to disk. Same engine + // as `anchore/sbom-action` so local + CI outputs agree. if (!opts.dockerImage) throw new Error('--docker image tag is required in docker mode'); const slug = opts.dockerImage.replace(/[^A-Za-z0-9._-]/g, '_'); const outFile = resolve(opts.output, `${slug}.spdx.json`); - const args = [ - 'scout', - 'sbom', - '--format', - 'spdx', - '--output', - outFile, - opts.dockerImage, - ]; - await run('docker', args, { dryRun: opts.dryRun }); + const args = [opts.dockerImage, '-o', `spdx-json=${outFile}`]; + await run('syft', args, { dryRun: opts.dryRun }); }; const invokedDirectly = (() => { From a0c9268148bf44b0da89ce08eb7c6408c19a7052 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Otto=20Boly=C3=B3s?= Date: Wed, 19 Aug 2026 16:18:34 +0200 Subject: [PATCH 09/15] fix(release): pin GH release to source SHA, recurse SBOMs, re-runable `create-gh-release` now names `vuln-scan` on its own `needs:` list directly instead of leaning on the transitive path through `publish-nuget`, so a future rewire that lets publish-nuget bypass the vuln scan (e.g. via an `if:` override) still hard-fails the release cut (F-CR-009). `gh-release-create.ts` gains three fixes: - `collectAssets` now uses `readdirSync(dir, { recursive: true, withFileTypes: true })` and filters to `.isFile()`, so nested SBOM layouts like `_manifest/spdx_2.2/manifest.spdx.json` (the shape `Microsoft.Sbom.DotNetTool` emits) reach the release attachment list; the prior code only walked the top level despite a docstring claiming one-level recursion. Nested dotfiles / dot-directories are still skipped. - A `--target ` option pins the tag to the SHA that produced the artefacts; the workflow now passes `${{ github.sha }}` so a concurrent `master` push cannot race the tag onto a different commit than the one the artefacts were built from. - The `gh release create` invocation is now idempotent: a pre-step swallows the "already exists" error by deleting the prior release + tag when the workflow re-runs on the same SHA. Prior behavior errored on the second run and left the pipeline red. Extends the test suite with fixtures for nested-asset discovery (`_manifest/spdx_2.2/manifest.spdx.json`), nested-dotfile skipping, and the new `--target` field. Closes findings F-CR-009, F-IMP collectAssets recursion, F-IMP idempotency, and F-IMP release-tag target from the PR #225 review. --- .github/workflows/release.yml | 12 +++++- tools/release/gh-release-create.test.ts | 45 +++++++++++++++++++- tools/release/gh-release-create.ts | 56 +++++++++++++++++++++---- 3 files changed, 101 insertions(+), 12 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 7428ebafe..8b77a7e53 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -432,7 +432,11 @@ jobs: # ---------------------------------------------------------------- create-gh-release: if: github.ref == 'refs/heads/master' - needs: [compute-version, publish-nuget, sbom, docker-manifest] + # `vuln-scan` is a transitive `needs` via `publish-nuget`, but + # naming it directly hard-fails the release cut if a future + # rewire lets `publish-nuget` bypass the scan (e.g. via + # `if:` overrides). + needs: [compute-version, publish-nuget, sbom, docker-manifest, vuln-scan] runs-on: ubuntu-latest permissions: contents: write @@ -467,4 +471,8 @@ jobs: env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} VERSION: ${{ needs.compute-version.outputs.version }} - run: npx tsx tools/release/gh-release-create.ts --version "$VERSION" --docker-image "trakhound/mtconnect-agent:$VERSION" + # Pin the tag to the SHA that produced the artefacts. Without + # `--target`, `gh release create` pins the tag to the tip of + # `master`, which drifts under concurrent merges. + TARGET_SHA: ${{ github.sha }} + run: npx tsx tools/release/gh-release-create.ts --version "$VERSION" --docker-image "trakhound/mtconnect-agent:$VERSION" --target "$TARGET_SHA" diff --git a/tools/release/gh-release-create.test.ts b/tools/release/gh-release-create.test.ts index 8cda1d53e..325603279 100644 --- a/tools/release/gh-release-create.test.ts +++ b/tools/release/gh-release-create.test.ts @@ -31,18 +31,21 @@ test('parseOptions: happy path — every field populated', () => { '--assets', '/tmp/nupkg', '--assets', '/tmp/sbom', '--docker-image', 'foo/bar:1', + '--target', 'deadbeef', ]); assert.equal(o.version, '7.0.0-dev.42'); assert.equal(o.repo, 'me/proj'); assert.deepEqual(o.assetDirs, ['/tmp/nupkg', '/tmp/sbom']); assert.equal(o.dockerImage, 'foo/bar:1'); + assert.equal(o.target, 'deadbeef'); assert.equal(o.dryRun, false); }); -test('parseOptions: defaults — repo, assetDirs, dockerImage undefined', () => { +test('parseOptions: defaults — repo, assetDirs, dockerImage, target undefined', () => { const o = parseOptions(['--version', '1.0.0']); assert.equal(o.repo, 'TrakHound/MTConnect.NET'); assert.equal(o.dockerImage, undefined); + assert.equal(o.target, undefined); // assetDirs default to build/output/nupkg + build/output/sbom under // repo root; assert shape rather than exact paths (repo-root-dependent). assert.equal(o.assetDirs.length, 2); @@ -114,6 +117,46 @@ test('collectAssets: aggregates across multiple dirs', () => { } }); +test('collectAssets: recurses into nested subdirectories', () => { + // `Microsoft.Sbom.DotNetTool` writes the SBOM under + // `_manifest/spdx_2.2/manifest.spdx.json` — pin that shape so the + // nested path is guaranteed to land on the release attachment list. + const dir = mkdtempSync(join(tmpdir(), 'gh-release-collect-nested-')); + try { + mkdirSync(join(dir, '_manifest', 'spdx_2.2'), { recursive: true }); + writeFileSync(join(dir, '_manifest', 'spdx_2.2', 'manifest.spdx.json'), 'x'); + writeFileSync(join(dir, 'top.nupkg'), 'x'); + const r = collectAssets([dir]); + const names = r.map((p) => p.split('/').pop()).sort(); + assert.deepEqual(names, ['manifest.spdx.json', 'top.nupkg']); + // Full path of the nested manifest survives, not just the basename. + assert.ok( + r.some((p) => p.endsWith('/_manifest/spdx_2.2/manifest.spdx.json')), + `no nested manifest path in ${r.join(', ')}`, + ); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + +test('collectAssets: skips nested dotfiles and dot-directories', () => { + // A `.git` directory or a `.DS_Store` under any depth must NOT + // leak into the attachment list — pin so the recursive walk + // respects the dotfile skip at every level, not just the top. + const dir = mkdtempSync(join(tmpdir(), 'gh-release-collect-nested-dot-')); + try { + mkdirSync(join(dir, 'sub', '.git'), { recursive: true }); + writeFileSync(join(dir, 'sub', '.git', 'HEAD'), 'x'); + writeFileSync(join(dir, 'sub', '.DS_Store'), 'x'); + writeFileSync(join(dir, 'sub', 'keep.nupkg'), 'x'); + const r = collectAssets([dir]); + const basenames = r.map((p) => p.split('/').pop()).sort(); + assert.deepEqual(basenames, ['keep.nupkg']); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + // ─── renderReleaseNotes ───────────────────────────────────────── test('renderReleaseNotes: header includes MTConnect.NET + version', () => { const notes = renderReleaseNotes('7.0.0-dev.42', [], undefined); diff --git a/tools/release/gh-release-create.ts b/tools/release/gh-release-create.ts index 8133c0914..5cec44cf8 100644 --- a/tools/release/gh-release-create.ts +++ b/tools/release/gh-release-create.ts @@ -15,8 +15,13 @@ * [--repo ] * [--assets ...] * [--docker-image ] + * [--target ] * [--dry-run] * + * `--target ` pins the tag to the commit that produced the + * artefacts. Omitting it lets `gh release create` pin the tag to the + * tip of the default branch, which drifts under concurrent pushes. + * * The release is always created as `--prerelease` (Phase 1 automates * only the dev pre-release cadence; stable releases stay under the * existing MTConnect.NET.Builder flow until a follow-up wires them @@ -39,6 +44,7 @@ export type Options = { repo: string; assetDirs: string[]; dockerImage: string | undefined; + target: string | undefined; dryRun: boolean; }; @@ -55,6 +61,7 @@ export const parseOptions = (argv: string[]): Options => { repo: { type: 'string' }, assets: { type: 'string', multiple: true }, 'docker-image': { type: 'string' }, + target: { type: 'string' }, }, }); if (!values.version) { @@ -69,13 +76,17 @@ export const parseOptions = (argv: string[]): Options => { repo: values.repo ?? 'TrakHound/MTConnect.NET', assetDirs, dockerImage: values['docker-image'], + target: values.target, dryRun, }; }; /** Enumerate assets across the requested directories, returning - * absolute paths. Recurses one level so `sbom/*.spdx.json` and - * `nupkg/*.nupkg` are both picked up without special-casing. */ + * absolute paths. Recurses so nested SBOM layouts such as + * `sbom/_manifest/spdx_2.2/manifest.spdx.json` (the shape + * `Microsoft.Sbom.DotNetTool` emits) are picked up alongside + * flat `nupkg/*.nupkg` files. Dotfiles are skipped at every level. + * Directories themselves are not attached — only files. */ export const collectAssets = (dirs: string[]): string[] => { const files: string[] = []; for (const dir of dirs) { @@ -83,10 +94,19 @@ export const collectAssets = (dirs: string[]): string[] => { process.stderr.write(`[gh-release-create] skipping missing dir: ${dir}\n`); continue; } - for (const name of readdirSync(dir)) { - const path = resolve(dir, name); - if (name.startsWith('.')) continue; - files.push(path); + // `readdirSync(dir, { recursive: true, withFileTypes: true })` + // returns `Dirent`s whose `parentPath` is the absolute directory + // containing the entry. Filter to regular files (skip directories + // and symlinks) and drop anything under a dotfile / dot-directory + // path segment. + for (const entry of readdirSync(dir, { recursive: true, withFileTypes: true })) { + if (!entry.isFile()) continue; + const parent = (entry as unknown as { parentPath?: string; path?: string }).parentPath + ?? (entry as unknown as { path?: string }).path + ?? dir; + const rel = resolve(parent, entry.name).slice(dir.length + 1); + if (rel.split('/').some((seg) => seg.startsWith('.'))) continue; + files.push(resolve(parent, entry.name)); } } return files; @@ -130,7 +150,10 @@ export const renderReleaseNotes = ( }; /** Entry point — write the notes file, then invoke `gh release - * create`. */ + * create`. Idempotent: if a release + tag for `v` already + * exist (a re-run of the workflow on the same SHA), the prior + * release + tag are deleted and re-cut so the assets attached match + * the current run. */ export const main = async (argv: string[]): Promise => { const opts = parseOptions(argv); @@ -144,10 +167,22 @@ export const main = async (argv: string[]): Promise => { writeFileSync(notesFile, notes, 'utf8'); } + const tag = `v${opts.version}`; + + // Idempotency guard — `gh release create` errors when the tag or + // release already exists. Silently swallow the "no such release" + // return by checking existence first, then delete both the release + // and the underlying tag so the fresh `create` below starts clean. + await run('sh', [ + '-c', + `gh release view ${tag} --repo ${opts.repo} >/dev/null 2>&1 && ` + + `gh release delete ${tag} --repo ${opts.repo} --yes --cleanup-tag || true`, + ], { dryRun: opts.dryRun, cwd: repoRoot }); + const args = [ 'release', 'create', - `v${opts.version}`, + tag, '--repo', opts.repo, '--title', @@ -155,8 +190,11 @@ export const main = async (argv: string[]): Promise => { '--notes-file', notesFile, '--prerelease', - ...assets, ]; + if (opts.target) { + args.push('--target', opts.target); + } + args.push(...assets); await run('gh', args, { dryRun: opts.dryRun, cwd: repoRoot }); }; From 4ec6cab522322cd66b009d542cc3c5cf24a92831 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Otto=20Boly=C3=B3s?= Date: Wed, 19 Aug 2026 16:19:24 +0200 Subject: [PATCH 10/15] chore(ci): drop unused commitlint-plugin-selective-scope dependency MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The plugin was declared under `plugins:` in `commitlint.config.mjs` and installed in `pre-merge.yml`'s commitlint step but no rule ever referenced it — the `scope-enum` rule that gates the pinned scope list is the standard rule from `@commitlint/config-conventional`, not the plugin's selective-scope override. Removing the declaration shrinks the CI install by one dependency and shrinks the local commit-msg attack surface. Closes findings F-CR-011, F-SEC-004, and the related SIMP recommendation from the PR #225 review. --- .github/workflows/pre-merge.yml | 3 +-- commitlint.config.mjs | 4 ---- 2 files changed, 1 insertion(+), 6 deletions(-) diff --git a/.github/workflows/pre-merge.yml b/.github/workflows/pre-merge.yml index 14e049da7..c2fe12b57 100644 --- a/.github/workflows/pre-merge.yml +++ b/.github/workflows/pre-merge.yml @@ -59,8 +59,7 @@ jobs: set -euo pipefail npm install --no-save --no-audit --no-fund \ @commitlint/cli@^19 \ - @commitlint/config-conventional@^19 \ - commitlint-plugin-selective-scope@^1 + @commitlint/config-conventional@^19 - name: Determine commit range id: range diff --git a/commitlint.config.mjs b/commitlint.config.mjs index 43fca81cf..098eab730 100644 --- a/commitlint.config.mjs +++ b/commitlint.config.mjs @@ -26,15 +26,11 @@ * * A missing scope is allowed (some cross-cutting changes have no * single home); a scope that is not on the pinned list is rejected. - * The `commitlint-plugin-selective-scope` plugin extends the standard - * `scope-enum` rule with per-scope granularity — kept in place to - * make a future per-scope constraint additive rather than a rewrite. */ /** @type {import('@commitlint/types').UserConfig} */ export default { extends: ['@commitlint/config-conventional'], - plugins: ['commitlint-plugin-selective-scope'], rules: { 'scope-enum': [ 2, From 37ed1e70f3ba1524fb0aeb02debae365e36524e4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Otto=20Boly=C3=B3s?= Date: Wed, 19 Aug 2026 16:21:19 +0200 Subject: [PATCH 11/15] fix(release): redact secret arg values in shell logs + hide NuGet key MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `shell.ts` gains a `SECRET_ARG_NAMES` set (`--api-key`, `--password`, `-p`, `--token`) and `renderCmd()` now redacts the value that follows any of them — both the `--name value` two-token form and the `--name=value` single-token form. The actual argv passed to the child spawn is untouched; only the log line is rewritten. Guards against a CI-log line that echoes the command from leaking a credential a caller placed on the argv. `nuget-push.ts` no longer places the raw API key on the parent's argv either. The `dotnet nuget push` invocation is wrapped in `sh -c '…$NUGET_API_KEY'` with the key in the child's `env:`, so the parent process's argv holds only the shell wrapper and the literal string `$NUGET_API_KEY`. `renderCmd`'s SECRET_ARG_NAMES redaction is the second line of defense for the CI log. Adds eight new test cases pinning the two-token, equals-form, multi-secret, end-of-argv, and API-surface contracts. Closes finding F-SEC-003 from the PR #225 review. --- tools/release/nuget-push.ts | 26 +++++++++++--- tools/release/shell.test.ts | 72 +++++++++++++++++++++++++++++++++++++ tools/release/shell.ts | 41 +++++++++++++++++++-- 3 files changed, 131 insertions(+), 8 deletions(-) diff --git a/tools/release/nuget-push.ts b/tools/release/nuget-push.ts index cb98522c9..9a1de4878 100644 --- a/tools/release/nuget-push.ts +++ b/tools/release/nuget-push.ts @@ -71,23 +71,39 @@ export const main = async (argv: string[]): Promise => { throw new Error(`No .nupkg files found in ${opts.input}`); } + // The API key is passed to `dotnet nuget push` via a spawn `env:` + // variable and expanded inside a `sh -c` wrapper. The parent + // process's argv holds `sh -c "…$NUGET_API_KEY"` — the literal + // key never appears there, so a wrapper like our `renderCmd()` + // (or an inspection of the parent's argv on the runner) cannot + // leak it. shell.ts's SECRET_ARG_NAMES redaction covers the CI + // log line as a second line of defence. for (const pkg of packages) { const path = resolve(opts.input, pkg); - const args = [ + const cmdParts = [ + 'dotnet', 'nuget', 'push', - path, + shellQuote(path), '--source', - opts.source, + shellQuote(opts.source), '--skip-duplicate', ]; if (opts.apiKey) { - args.push('--api-key', opts.apiKey); + cmdParts.push('--api-key', '"$NUGET_API_KEY"'); } - await run('dotnet', args, { dryRun: opts.dryRun }); + await run('sh', ['-c', cmdParts.join(' ')], { + dryRun: opts.dryRun, + env: opts.apiKey ? { NUGET_API_KEY: opts.apiKey } : undefined, + }); } }; +/** Shell-safe quote — single-quotes with any embedded single-quote + * escaped as `'\''`. Used only for interpolation into the `sh -c` + * string above so a package path containing spaces round-trips. */ +const shellQuote = (s: string): string => `'${s.replace(/'/g, `'\\''`)}'`; + const invokedDirectly = (() => { const entry = process.argv[1]; if (!entry) return false; diff --git a/tools/release/shell.test.ts b/tools/release/shell.test.ts index af5f7f72a..1d7c7d7c7 100644 --- a/tools/release/shell.test.ts +++ b/tools/release/shell.test.ts @@ -18,6 +18,7 @@ import { strict as assert } from 'node:assert'; import { + SECRET_ARG_NAMES, optionalEnv, parseDryRun, renderCmd, @@ -92,6 +93,77 @@ test('renderCmd: empty argv renders as the bare cmd', () => { assert.equal(renderCmd('gh', []), 'gh'); }); +// ─── renderCmd: secret redaction ─────────────────────────────── +test('renderCmd: `--api-key value` value is redacted, flag preserved', () => { + // Documented contract: the value AFTER any SECRET_ARG_NAMES entry is + // replaced by `` in the log line only. Pin the exact + // rendering so a future refactor cannot silently leak the key. + assert.equal( + renderCmd('dotnet', ['nuget', 'push', '--api-key', 'topsecret']), + 'dotnet nuget push --api-key ', + ); +}); + +test('renderCmd: `--password value` and `-p value` values are redacted', () => { + assert.equal( + renderCmd('mysql', ['-u', 'root', '--password', 'hunter2']), + 'mysql -u root --password ', + ); + // `-p` alone; the arg that follows is the secret. + assert.equal( + renderCmd('curl', ['-u', 'user', '-p', 'hunter2', 'https://x']), + 'curl -u user -p https://x', + ); +}); + +test('renderCmd: `--token value` is redacted', () => { + assert.equal( + renderCmd('gh', ['auth', 'login', '--token', 'ghp_xxx']), + 'gh auth login --token ', + ); +}); + +test('renderCmd: `--api-key=value` (equals form) is redacted, key preserved', () => { + // The equals-form arg is a single argv token — the redactor must + // detect it and rewrite only the RHS. + assert.equal( + renderCmd('dotnet', ['nuget', 'push', '--api-key=topsecret']), + 'dotnet nuget push --api-key=', + ); + assert.equal( + renderCmd('gh', ['--token=ghp_xxx', 'auth']), + 'gh --token= auth', + ); +}); + +test('renderCmd: multiple secret args each redact only their own value', () => { + // Guards against a "sticky" redactNext state that would drop + // non-secret trailing args. + assert.equal( + renderCmd('cli', [ + '--api-key', 'k1', '--source', 'https://x', + '--token', 'k2', '--verbose', + ]), + 'cli --api-key --source https://x --token --verbose', + ); +}); + +test('renderCmd: secret arg at end-of-argv (no value) does not crash', () => { + // A malformed invocation where a secret flag is the last arg with no + // value. `redactNext` is set but never consumed — must not throw and + // must render the flag alone. + assert.equal(renderCmd('dotnet', ['nuget', 'push', '--api-key']), 'dotnet nuget push --api-key'); +}); + +test('renderCmd: SECRET_ARG_NAMES export lists the documented four names', () => { + // Pins the API contract so a rename / deletion is a test failure + // rather than a silent regression. + assert.deepEqual( + [...SECRET_ARG_NAMES].sort(), + ['--api-key', '--password', '--token', '-p'].sort(), + ); +}); + // ─── parseDryRun ──────────────────────────────────────────────── test('parseDryRun: --dry-run flag detected, stripped from rest', () => { const r = parseDryRun(['--version', '1.0.0', '--dry-run']); diff --git a/tools/release/shell.ts b/tools/release/shell.ts index 9ccbecc6a..84d118656 100644 --- a/tools/release/shell.ts +++ b/tools/release/shell.ts @@ -56,11 +56,46 @@ export const run = async ( }); }; +/** Argument names whose IMMEDIATELY-FOLLOWING value is a credential. + * When any of these appears in the argv passed to `renderCmd`, the + * next arg is redacted in the printed log line only — the value that + * reaches the child process's argv is untouched. Applies to the + * `--name value` form; the `--name=value` form is redacted separately + * (the equals-form arg is a single token). */ +export const SECRET_ARG_NAMES: ReadonlySet = new Set([ + '--api-key', + '--password', + '-p', + '--token', +]); + /** Render a command for logging — quoting any arg that contains - * whitespace or shell-special characters. Human-readable, not - * round-trip parseable. */ + * whitespace or shell-special characters, and redacting the value + * after any `SECRET_ARG_NAMES` arg so credentials never surface in + * CI logs. Human-readable, not round-trip parseable. */ export const renderCmd = (cmd: string, args: string[]): string => { - return [cmd, ...args.map(quoteForLog)].join(' '); + const rendered: string[] = []; + let redactNext = false; + for (const arg of args) { + if (redactNext) { + rendered.push(''); + redactNext = false; + continue; + } + // `--name=value` form — split at the first `=` and redact the RHS + // whenever the LHS is a known secret arg name. + const eqIdx = arg.indexOf('='); + if (eqIdx > 0) { + const lhs = arg.slice(0, eqIdx); + if (SECRET_ARG_NAMES.has(lhs)) { + rendered.push(`${lhs}=`); + continue; + } + } + rendered.push(quoteForLog(arg)); + if (SECRET_ARG_NAMES.has(arg)) redactNext = true; + } + return [cmd, ...rendered].join(' '); }; /** Wrap in double quotes if the arg contains anything shell would From a065d868e63122aca0a129a10abf0c4431c97e98 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Otto=20Boly=C3=B3s?= Date: Wed, 19 Aug 2026 16:21:44 +0200 Subject: [PATCH 12/15] fix(ci): pin Microsoft.Sbom.DotNetTool install to v4.1.5 `dotnet tool install --global` without `--version` resolves to the latest published NuGet version at install-time, which lets a compromised or accidentally-broken upstream publish silently land on a release-cutting runner. Pin to the current known-good release verified via `gh api repos/microsoft/sbom-tool/releases/latest`. Closes finding F-SEC-007 from the PR #225 review. --- .github/workflows/release.yml | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 8b77a7e53..68b69cdbf 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -275,7 +275,12 @@ jobs: run: npm ci - name: Install sbom-tool - run: dotnet tool install --global Microsoft.Sbom.DotNetTool + # Pin to a verified upstream release rather than "whatever + # `dotnet tool install` resolves today" so a compromised or + # accidentally-broken publish upstream cannot silently land on + # a release-cutting runner. Bump this in tandem with the + # `microsoft/sbom-tool` release cadence. + run: dotnet tool install --global Microsoft.Sbom.DotNetTool --version 4.1.5 - name: Download .nupkg artefacts uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 From 1342b16e70492df7aa7dd26a3cecd2b60fa28443 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Otto=20Boly=C3=B3s?= Date: Wed, 19 Aug 2026 16:23:30 +0200 Subject: [PATCH 13/15] docs(release): fix secrets table, header cap, mermaid graph, README MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Doc corrections lifted out of the PR #225 review: - `release-pipeline.md`: `DOCKERHUB_USERNAME` + `DOCKERHUB_TOKEN` are also consumed by `vuln-scan` (Trivy needs them to pull the image before scanning). Adds a `needs:` column to the jobs table and a mermaid `graph LR` of the same dependency graph — the two surfaces stay in step because they describe the same `release.yml` shape. - `commit-format.md`: the `header-max-length` rule measures the whole first line, not the subject alone — reword to state that plus the `body-leading-blank` + `footer-leading-blank` rules the config also enforces. - `tools-release.md`: adds a `shell.ts` section documenting the shared helper's surface (`run`, `renderCmd` w/ secret redaction, `parseDryRun`, `requireEnv`, `optionalEnv`) and reworks the opener to exclude helpers explicitly; the old text falsely claimed every script under `tools/release/` exposes a `main`. - `tools/dev/README.md` + `tools/docs/README.md`: shrunk from placeholder stubs to one-line pointers at `/reference/cli` per the "no placeholder README" project convention. Closes findings F-DOC-003, F-DOC-004, F-DOC-006, F-DOC-008, and F-DOC-010 from the PR #225 review. --- docs/development/commit-format.md | 13 +++++-- docs/development/release-pipeline.md | 51 +++++++++++++++++++++------- docs/development/tools-release.md | 24 +++++++++++-- tools/dev/README.md | 19 +++-------- tools/docs/README.md | 15 +++----- 5 files changed, 79 insertions(+), 43 deletions(-) diff --git a/docs/development/commit-format.md b/docs/development/commit-format.md index a850cc542..29e732cdd 100644 --- a/docs/development/commit-format.md +++ b/docs/development/commit-format.md @@ -22,8 +22,17 @@ place. `refactor`, `perf`, `test`, `build`, `ci`, `revert`. - `` — optional, but if present must be one of the pinned scopes below. -- `` — imperative, ≤70 characters. Sentence-cased, no - trailing full stop. +- `` — imperative, sentence-cased, no trailing full stop. +- The header (type + scope + subject, including the punctuation) is + capped at 70 characters by `commitlint`'s `header-max-length` rule. + The rule measures the whole first line, not just the subject — a + long scope eats into the subject budget. +- The body and each footer must be separated from the header (and + from each other) by one blank line — `commitlint` enforces + `body-leading-blank` and `footer-leading-blank`. Body and footer + lines have no formal length cap in the config today; keep them at + the Conventional-Commits recommended 100-character wrap for + readability on `git log --oneline`-adjacent tooling. - A `!` before the colon (`feat!:`, `fix(scope)!:`) marks a breaking change and triggers a major-version bump in the release pipeline. - A `BREAKING CHANGE:` footer has the same effect. diff --git a/docs/development/release-pipeline.md b/docs/development/release-pipeline.md index afa1b04b4..d86abe82a 100644 --- a/docs/development/release-pipeline.md +++ b/docs/development/release-pipeline.md @@ -14,17 +14,44 @@ collapses into the latest push and cancels any in-flight prior run. ## Jobs -| Job | Runner | Purpose | -| --- | --- | --- | -| `compute-version` | `ubuntu-latest` | Runs `tools/ci/semver-bump.ts` to derive `-dev.` from the commit range since the last stable tag. | -| `pack` | `ubuntu-latest` | `dotnet pack MTConnect.NET.sln -c Release`, uploads every `.nupkg` + `.snupkg` as the `nupkg` artefact. | -| `docker-amd64` | `ubuntu-latest` | Native `linux/amd64` image via `docker buildx build`, pushed as `:-amd64`. | -| `docker-arm64` | `ubuntu-24.04-arm` | Native `linux/arm64` image, pushed as `:-arm64`. | -| `docker-manifest` | `ubuntu-latest` | Merges the two per-arch tags into a single multi-arch tag `:` via `docker buildx imagetools create`. | -| `sbom` | `ubuntu-latest` | SPDX SBOMs — `Microsoft.Sbom.DotNetTool` over the `.nupkg` set + `anchore/sbom-action` (syft) over the merged image. | -| `vuln-scan` | `ubuntu-latest` | `aquasecurity/trivy-action` scans the `.nupkg` set and the Docker image; SARIF uploaded to the Security tab. | -| `publish-nuget` | `ubuntu-latest` | `dotnet nuget push` every `.nupkg` to nuget.org via `NUGET_API_KEY`. | -| `create-gh-release` | `ubuntu-latest` | `gh release create v --prerelease` with SBOMs + `.nupkg`s attached and the Docker image ref in the notes. | +| Job | Runner | `needs:` | Purpose | +| --- | --- | --- | --- | +| `compute-version` | `ubuntu-latest` | — | Runs `tools/ci/semver-bump.ts` to derive `-dev.` from the commit range since the last stable tag. | +| `pack` | `ubuntu-latest` | `compute-version` | `dotnet pack MTConnect.NET.sln -c Release`, uploads every `.nupkg` + `.snupkg` as the `nupkg` artefact. | +| `docker-amd64` | `ubuntu-latest` | `compute-version` | Native `linux/amd64` image via `docker buildx build`, pushed as `:-amd64`. | +| `docker-arm64` | `ubuntu-24.04-arm` | `compute-version` | Native `linux/arm64` image, pushed as `:-arm64`. | +| `docker-manifest` | `ubuntu-latest` | `compute-version`, `docker-amd64`, `docker-arm64` | Merges the two per-arch tags into a single multi-arch tag `:` via `docker buildx imagetools create`. | +| `sbom` | `ubuntu-latest` | `compute-version`, `pack`, `docker-manifest` | SPDX SBOMs — `Microsoft.Sbom.DotNetTool` over the `.nupkg` set + `anchore/sbom-action` (syft) over the merged image. | +| `vuln-scan` | `ubuntu-latest` | `compute-version`, `pack`, `docker-manifest` | `aquasecurity/trivy-action` scans the `.nupkg` set and the Docker image; SARIF uploaded to the Security tab. | +| `publish-nuget` | `ubuntu-latest` | `compute-version`, `pack`, `sbom`, `vuln-scan` | `dotnet nuget push` every `.nupkg` to nuget.org via `NUGET_API_KEY`. | +| `create-gh-release` | `ubuntu-latest` | `compute-version`, `publish-nuget`, `sbom`, `docker-manifest`, `vuln-scan` | `gh release create v --prerelease` with SBOMs + `.nupkg`s attached and the Docker image ref in the notes. | + +The same graph as a mermaid diagram: + +```mermaid +graph LR + cv[compute-version] --> pack + cv --> amd[docker-amd64] + cv --> arm[docker-arm64] + amd --> man[docker-manifest] + arm --> man + cv --> man + pack --> sbom + man --> sbom + cv --> sbom + pack --> vs[vuln-scan] + man --> vs + cv --> vs + pack --> pn[publish-nuget] + sbom --> pn + vs --> pn + cv --> pn + pn --> gh[create-gh-release] + sbom --> gh + man --> gh + vs --> gh + cv --> gh +``` ## Semver-bump algorithm @@ -48,7 +75,7 @@ collapses into the latest push and cancels any in-flight prior run. | Name | Used by | Notes | | --- | --- | --- | | `NUGET_API_KEY` | `publish-nuget` | Classic nuget.org API key. Phase 1 does not use OIDC; SignPath is deferred. | -| `DOCKERHUB_USERNAME` | `docker-amd64`, `docker-arm64`, `docker-manifest`, `sbom` | Docker Hub account owning the `trakhound` namespace. | +| `DOCKERHUB_USERNAME` | `docker-amd64`, `docker-arm64`, `docker-manifest`, `sbom`, `vuln-scan` | Docker Hub account owning the `trakhound` namespace. | | `DOCKERHUB_TOKEN` | as above | Personal access token scoped to `trakhound/mtconnect-agent` writes. | | `GITHUB_TOKEN` | `create-gh-release` | Auto-provisioned; `contents: write` scope. | diff --git a/docs/development/tools-release.md b/docs/development/tools-release.md index ad8631196..b50d0ee5c 100644 --- a/docs/development/tools-release.md +++ b/docs/development/tools-release.md @@ -5,9 +5,10 @@ Every script under `tools/release/` is a TypeScript file executed via only production consumer; scripts also run standalone under `--dry-run` for local verification. -Every script exposes a `main(argv)` export and a +Every top-level script exposes a `main(argv)` export and a run-when-invoked-directly shim, so it doubles as a library and a -CLI. +CLI. `shell.ts` is a shared helper — it has no `main(argv)`, only +the reusable spawn-and-log surface every other script imports. ## `pack.ts` @@ -80,8 +81,25 @@ tsx tools/release/gh-release-create.ts --version 7.0.0-dev.42 \ --docker-image trakhound/mtconnect-agent:7.0.0-dev.42 ``` +## `shell.ts` — shared helper + +Not a CLI. Exposes: + +- `run(cmd, args, opts?)` — inherit-stdio `spawn` wrapper that + throws on non-zero exit and echoes each command it runs. Under + `opts.dryRun`, prints `[dry-run] ` and skips the spawn. +- `renderCmd(cmd, args)` — human-readable rendering of the command + as it would appear on stdout. Redacts the value that follows any + `SECRET_ARG_NAMES` arg (`--api-key`, `--password`, `-p`, + `--token`) so a CI-log echo cannot leak a credential. +- `parseDryRun(argv)` — pulls the `--dry-run` flag out of an argv + list; every CLI above uses this to preserve a uniform flag + surface without pulling in a heavier CLI library. +- `requireEnv(name)` / `optionalEnv(name)` — throwing / undefined + variants for environment-variable lookup. + ## `--dry-run` -Every script accepts `--dry-run`. Under that flag every subprocess +Every CLI accepts `--dry-run`. Under that flag every subprocess invocation is logged instead of executed — the shape of the pipeline can be verified end-to-end on a workstation without publishing. diff --git a/tools/dev/README.md b/tools/dev/README.md index 208b267b1..5b00d8598 100644 --- a/tools/dev/README.md +++ b/tools/dev/README.md @@ -1,16 +1,5 @@ -# `tools/dev/` — local development-loop helpers +# `tools/dev/` -This directory will hold repo-side scripts that speed up the local -inner loop — spin up a demo agent against a fake adapter, tail agent -logs while a change is being iterated, regenerate one narrow slice of -the docs without paying the full `npm run regen` wall-clock, and -similar. - -Empty on purpose. The first helper will be added in a follow-up PR -once the release pipeline in `tools/release/` is stable and the -inner-loop pain points are cleaner to prioritise. - -For the currently-shipped inner-loop scripts (`tools/dotnet.sh`, -`tools/test.sh`) see the sibling docs under `docs/cli/dotnet-sh` and -`docs/cli/test-sh` — those pre-date this reorganisation and stay at -`tools/` root so their existing CI + doc references are undisturbed. +This directory hosts repo-side scripts that speed up the local +inner development loop. Individual scripts are documented under the +site's [`/reference/cli`](/reference/cli) section as they are added. diff --git a/tools/docs/README.md b/tools/docs/README.md index 84560b160..983e5433f 100644 --- a/tools/docs/README.md +++ b/tools/docs/README.md @@ -1,12 +1,5 @@ -# `tools/docs/` — documentation-generation helpers +# `tools/docs/` -This directory will hold repo-side scripts that produce inputs the -VitePress site consumes — spec cross-references, wire-format sample -regeneration, per-version compliance matrix rebuilds, and similar. - -Empty on purpose. The existing generators live under -`docs/scripts/generate-api-ref.sh` and -`docs/scripts/generate-reference.sh` (invoked by `docs/`'s npm -`predev` / `prebuild` hooks) and stay there for now to keep the -docs-site self-contained. Follow-up PRs will migrate cross-cutting -generators to this directory as the release pipeline lands. +This directory hosts repo-side scripts that produce inputs the +VitePress site consumes. Individual scripts are documented under the +site's [`/reference/cli`](/reference/cli) section as they are added. From 8f77c3a348f19dc32a316ab4bff5f8c1f3f509e1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Otto=20Boly=C3=B3s?= Date: Wed, 19 Aug 2026 16:24:22 +0200 Subject: [PATCH 14/15] ci(test): wire tools/ npm test + typecheck into pre-merge required set MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `tools/` gained TypeScript unit suites for `ci/*.ts` and every `release/*.ts` script (three commits earlier), but they weren't running under any workflow — the pre-merge gate only ran commitlint, and `dotnet.yml` covers only the .NET matrix. Adds a second job `unit-tests-tools` that runs `npm ci && npm run typecheck && npm test` under `tools/` so those suites participate in the required-check set. The job stays lightweight (Node.js only, no dotnet, no docker) so its wall-clock stays under a minute and it does not delay the merge on a green run. Closes the "tests exist but not wired to CI" gap flagged in the PR #225 review. --- .github/workflows/pre-merge.yml | 47 +++++++++++++++++++++++++++------ 1 file changed, 39 insertions(+), 8 deletions(-) diff --git a/.github/workflows/pre-merge.yml b/.github/workflows/pre-merge.yml index c2fe12b57..65e37e94a 100644 --- a/.github/workflows/pre-merge.yml +++ b/.github/workflows/pre-merge.yml @@ -3,7 +3,7 @@ name: pre-merge # ------------------------------------------------------------------ # Per-PR gate that runs on every non-draft PR targeting `master`. # -# Two responsibilities: +# Three responsibilities: # # 1. `commitlint` — every commit in the range # `..HEAD` must parse under @@ -11,14 +11,18 @@ name: pre-merge # Conventional Commits contract; this is the promise the release # pipeline's semver-bump relies on. # -# 2. Test matrix — the pre-existing `dotnet.yml` workflow already +# 2. `unit-tests-tools` — runs `npm test` under `tools/` so the +# TypeScript unit suites for `tools/ci/*.ts` and +# `tools/release/*.ts` participate in the required-check set. +# Those tests only cover TypeScript in `tools/`; the .NET matrix +# remains the responsibility of `dotnet.yml`. +# +# 3. Test matrix — the pre-existing `dotnet.yml` workflow already # runs the ubuntu-latest + windows-latest matrix on every PR; -# this file does NOT duplicate it. The tests run under the -# existing workflow name; adding the commitlint check here as a -# new required check is the phase-1 delta. +# this file does NOT duplicate it. # -# The workflow is intentionally lightweight — no dotnet, no docker, -# no long-lived jobs. commitlint alone. +# The workflow is intentionally lightweight — no dotnet build, no +# docker, no long-lived jobs. # ------------------------------------------------------------------ on: @@ -32,7 +36,7 @@ permissions: concurrency: # A rapid succession of pushes to the PR head collapses into the - # latest one; older commitlint runs are cancelled. + # latest one; older commitlint + tools-test runs are cancelled. group: pre-merge-${{ github.event.pull_request.number }} cancel-in-progress: true @@ -79,3 +83,30 @@ jobs: FROM: ${{ steps.range.outputs.from }} TO: ${{ steps.range.outputs.to }} run: npx commitlint --from "$FROM" --to "$TO" --verbose + + unit-tests-tools: + # Skip drafts — matches the same gate on commitlint above. + if: github.event.pull_request.draft == false + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 + + - name: Setup Node.js + uses: actions/setup-node@1e60f620b9541d16bece96c5465dc8ee9832be0b # v4 + with: + node-version: '20' + cache: 'npm' + cache-dependency-path: tools/package-lock.json + + - name: Install tools deps + working-directory: tools + run: npm ci + + - name: Typecheck + working-directory: tools + run: npm run typecheck + + - name: Run unit tests + working-directory: tools + run: npm test From a6e96e0ac4f50d240b3dabc1d4b0c7cc4bdffd60 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Otto=20Boly=C3=B3s?= Date: Wed, 19 Aug 2026 16:26:14 +0200 Subject: [PATCH 15/15] docs(release): link follow-up issue #237 for the NuGet quarantine Threads the just-filed follow-up issue number into the `deps-update.yml` header comment, the NuGet-bump step comment, and the `deps-update.md` supply-chain section so the "no NuGet quarantine" caveat is tracked to an actionable next step rather than left as an open thread. --- .github/workflows/deps-update.yml | 4 ++-- docs/development/deps-update.md | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/deps-update.yml b/.github/workflows/deps-update.yml index f1a59c9b6..33e07a3b5 100644 --- a/.github/workflows/deps-update.yml +++ b/.github/workflows/deps-update.yml @@ -20,7 +20,7 @@ name: deps-update # window is excluded automatically. NuGet does NOT enforce the # quarantine: `dotnet-outdated` has no built-in age filter, so NuGet # bumps rely on downstream CI + review to catch a hot-published bad -# release. Adding a NuGet quarantine is tracked as a follow-up. +# release. Adding a NuGet quarantine is tracked in issue #237. # # A prior npm+NuGet PR that is still open when the workflow re-fires # is closed as superseded — only one bulk PR from this workflow is @@ -139,7 +139,7 @@ jobs: # NuGet bumps do NOT participate in the MIN_AGE_DAYS # quarantine — downstream CI + reviewer eyes are the only # protection against a hot-published bad release. Adding a - # proper quarantine is tracked as a follow-up. + # proper quarantine is tracked in issue #237. # ------------------------------------------------------------ - name: Bump NuGet package versions run: | diff --git a/docs/development/deps-update.md b/docs/development/deps-update.md index 7c67be2e4..cfb38ddfa 100644 --- a/docs/development/deps-update.md +++ b/docs/development/deps-update.md @@ -31,8 +31,8 @@ younger than `env.MIN_AGE_DAYS` (default seven) days is accepted: - **NuGet packages** — **no quarantine**. `dotnet-outdated` has no built-in age filter, so a hot-published bad NuGet release will land in the weekly PR unfiltered; downstream CI + reviewer eyes are the - only line of defence. A proper NuGet quarantine is tracked as a - follow-up. + only line of defence. A proper NuGet quarantine is tracked in + issue [#237](https://github.com/TrakHound/MTConnect.NET/issues/237). The invariant catches the standard OSS "poisoned publish yanked within a week" response window for the three ecosystems that support