diff --git a/.github/workflows/prepare-release.yaml b/.github/workflows/prepare-release.yaml index 4aa07d1..df2e937 100644 --- a/.github/workflows/prepare-release.yaml +++ b/.github/workflows/prepare-release.yaml @@ -10,6 +10,8 @@ name: "Prepare Release" # is tagged or published here, and NOTHING is pushed directly to master: the bump # reaches master only when a human merges the PR, so the org-wide "pull request # required" ruleset stays satisfied without any bypass. +# A prepare run REFUSES to start while a previous release PR is still open, so +# at most one release is in flight at a time. # # The bump type decides the channel: `prerelease` produces a `-.N` suffix # (channel = that preid, e.g. dev/rc); patch/minor/major produce a stable @@ -42,10 +44,13 @@ defaults: run: shell: bash -# One prep run per bump type, and never cancel one mid-flight: a cancelled run can -# leave a pushed prep branch with no PR opened for it. +# One prep run at a time — a constant group, not per-bump-type: the open-PR +# guard below declares one release in flight, and a per-input group would let +# two DIFFERENT-bump dispatches run concurrently, both passing the guard before +# either PR exists. Never cancel one mid-flight: a cancelled run can leave a +# pushed prep branch with no PR opened for it. concurrency: - group: prepare-release-${{ inputs.bump }}-${{ inputs.preid }} + group: prepare-release cancel-in-progress: false jobs: @@ -66,6 +71,25 @@ jobs: # Unshallowed so origin/ exists for --force-with-lease. fetch-depth: 0 + - name: Refuse when a release PR is already open + run: | + set -euo pipefail + # One release in flight at a time: an un-merged release/prep-v* PR + # means a previous prepare was never resolved — a second bump would + # race it (two competing version numbers for the next tag). Merge or + # close the open PR first; this run fails by design. Same-repo, + # bot-authored PRs only: prep PRs are always bot-created here, and an + # unfiltered match would let ANY fork PR named release/prep-v* block + # releases indefinitely (the repo is public) — a hand-created prep PR + # is deliberately invisible to this guard; do not "fix" that away. + open_release_prs="$(gh pr list --base master --state open --limit 100 \ + --json number,headRefName,isCrossRepository,author \ + --jq '[.[] | select(.isCrossRepository == false and .author.login == "app/github-actions" and (.headRefName | startswith("release/prep-v")))] | map("#\(.number) (\(.headRefName))") | join(", ")')" + if [[ -n "$open_release_prs" ]]; then + echo "::error::release PR(s) already open: ${open_release_prs} — merge or close before preparing a new release" >&2 + exit 1 + fi + - name: Setup pnpm uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4.3.0 diff --git a/.github/workflows/tag-release.yaml b/.github/workflows/tag-release.yaml index 127a57f..4e460e8 100644 --- a/.github/workflows/tag-release.yaml +++ b/.github/workflows/tag-release.yaml @@ -7,13 +7,29 @@ name: "Tag Release" # the version's channel, tags master, and publishes a GitHub release. Nothing is # pushed to master here, so the org-wide "pull request required" ruleset needs no # bypass. +# On stable releases a follow-up job mints a 1-hour wire-release-bot token and +# fires a repository_dispatch to Wire-Network/wire-tools-ts so its +# update-wireio-deps.yaml opens a PR updating every `@wireio/*` range. # # No inputs: the release version is whatever master currently carries. Its suffix # IS the channel: no suffix => stable => npm dist-tag `latest`; a `-.N` # suffix => that preid is the dist-tag (e.g. dev, rc). The two human gates -# (dispatch + Environment approval) are the safety, not a typed version. +# (the release-PR merge + the release Environment's required reviewers) are the +# safety, not a typed version. on: + # Auto-triggered when a release/prep-v* PR merges to master — the human + # merge is gate 1; the `release` Environment's required reviewers are gate 2 + # (an org-configuration action of this change — the environment ships with + # no protection rules). The merge must be performed by a HUMAN: an + # auto-merge or any GITHUB_TOKEN-driven merge is suppressed by GitHub and + # fires no run — use workflow_dispatch (the standing manual/recovery path) + # if that ever changes. `pull_request closed` fires for every PR; the + # job-level `if` below skips everything but merged, same-repo release-prep + # PRs (skipped runs on ordinary merges are cosmetic noise). + pull_request: + types: [closed] + branches: [master] workflow_dispatch: {} permissions: @@ -31,12 +47,24 @@ concurrency: jobs: tag: name: Tag master and publish + # Manual dispatch, or a MERGED, SAME-REPO release-prep PR — everything + # else (unmerged closes, non-release branches, fork PRs whose head happens + # to be named release/prep-v*) skips, and skipped `tag` also skips the + # dependent dispatch job via `needs`. + if: >- + github.event_name == 'workflow_dispatch' || + (github.event.pull_request.merged == true && + github.event.pull_request.head.repo.full_name == github.repository && + startsWith(github.event.pull_request.head.ref, 'release/prep-v')) runs-on: ubuntu-latest # Required reviewers on this Environment are gate 2 -- nothing below runs until # a human approves the deployment. environment: release env: GH_TOKEN: ${{ github.token }} + outputs: + dist_tag: ${{ steps.resolve.outputs.dist_tag }} + version: ${{ steps.resolve.outputs.version }} steps: - name: Checkout uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 @@ -143,3 +171,43 @@ jobs: args+=(--prerelease) fi gh release create "$TAG" "${args[@]}" + + dispatch-wire-tools-ts-update: + name: Dispatch the wire-tools-ts dependency update + needs: tag + # Stable releases only — prerelease channels must never drive downstream + # updates. A failure here cannot touch the published release (separate + # job, runs after publish/tag/release); the manual fallback is + # wire-tools-ts's own workflow_dispatch on update-wireio-deps.yaml. + if: needs.tag.outputs.dist_tag == 'latest' + runs-on: ubuntu-latest + timeout-minutes: 10 + # Nothing here uses GITHUB_TOKEN — the cross-repo call rides the App token. + permissions: {} + steps: + # wire-release-bot: the org's release App (contents: write + metadata: + # read — exactly the repository_dispatch minimum; its installation must + # include wire-tools-ts, see the plan's org-configuration actions). A + # 1-hour installation token downscoped to wire-tools-ts replaces any + # long-lived PAT, and is used ONLY for the dispatch API call below — + # NEVER for git (the App is a ruleset bypass actor). This is the App's + # ONE sanctioned use, pinned by the never-use-wire-release-bot rule. + - name: Mint the dispatch token + id: dispatch-token + uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 + with: + app-id: ${{ secrets.RELEASE_BOT_APP_ID }} + private-key: ${{ secrets.RELEASE_BOT_PRIVATE_KEY }} + owner: Wire-Network + repositories: wire-tools-ts + permission-contents: write + + - name: Fire the repository_dispatch + env: + GH_TOKEN: ${{ steps.dispatch-token.outputs.token }} + RELEASE_VERSION: ${{ needs.tag.outputs.version }} + run: | + set -euo pipefail + gh api "repos/Wire-Network/wire-tools-ts/dispatches" \ + -f event_type=wire-libraries-ts-release \ + -f "client_payload[reason]=wire-libraries-ts release v${RELEASE_VERSION}" diff --git a/.github/workflows/update-wireio-deps.yaml b/.github/workflows/update-wireio-deps.yaml new file mode 100644 index 0000000..c9dc79e --- /dev/null +++ b/.github/workflows/update-wireio-deps.yaml @@ -0,0 +1,268 @@ +name: "Update @wireio Dependencies" + +# Opens a PR that updates every `@wireio/*` dependency this monorepo declares +# to its own latest npm version (scripts/update-wireio-deps.mjs — origin- +# agnostic, per-package versions, range operators preserved). +# +# Two triggers: +# - repository_dispatch `wire-libraries-ts-release` — fired by +# wire-libraries-ts's tag-release.yaml after every STABLE publish; the +# payload carries a human-readable `reason`; +# - workflow_dispatch — manual, with the same optional `reason` input. +# +# The reason is UNTRUSTED input: it rides env vars (never interpolated into a +# script body — the same discipline as prepare-release.yaml's inputs) and is +# rendered into the PR body via a quoted printf only. Versions come from npm +# itself (`dist-tags.latest`, --prefer-online) — the payload cannot steer what +# is written. +# +# THIS WORKFLOW IS THE CI GATE for the PR it opens: bot-authored PRs get no +# checks in wire-tools-ts and only approval-gated ones in wire-libraries-ts, +# so the update-and-gate job runs the same install+build+test sequence +# ci.yaml and tag-release.yaml use, against the UPDATED versions, BEFORE any +# PR opens. A red update-and-gate run (with its JUnit artifact) is the +# failure signal. +# +# Least-privilege split: update-and-gate executes freshly-resolved third-party +# code, so it runs under `contents: read` with no persisted git credentials; +# open-pr holds the write scopes and runs NO third-party code — the two hand +# off through a same-run artifact. Every run gets a NEW timestamped branch and +# PR (reviewers: jglanz + bearcubsvet); nothing is ever force-pushed. + +on: + workflow_dispatch: + inputs: + reason: + description: "Why this update is being dispatched (lands in the PR body)" + type: string + required: false + +# Explicit zero floor — each job grants exactly what it needs (job-level +# permissions replace this entirely). +permissions: {} + +defaults: + run: + shell: bash + +# One update at a time; never cancel one mid-flight (a cancelled run can leave +# a pushed branch with no PR opened for it). +concurrency: + group: update-wireio-deps + cancel-in-progress: false + +jobs: + update-and-gate: + name: Update the ranges and run the gate + runs-on: ubuntu-latest + timeout-minutes: 45 + # This job executes freshly-resolved third-party code (the gate's installs + # and builds), so it carries NO write token. + permissions: + contents: read + outputs: + changed: ${{ steps.detect.outputs.changed }} + steps: + - name: Checkout + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + with: + # Both jobs pin the SAME commit — the event-time master head — so the + # open-pr overlay can never silently revert a change that lands on + # master mid-run. + ref: ${{ github.sha }} + # No git writes happen in this job — don't leave a token in + # .git/config next to third-party code. + persist-credentials: false + + - name: Setup pnpm + uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4.3.0 + + - name: Setup Node + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 + with: + node-version: 24 + + - name: Install script dependencies + # The update script itself needs only the root devDeps (zx); the full + # gate install below re-resolves the UPDATED ranges. + # --no-frozen-lockfile: wire-libraries-ts commits a lockfile that + # legitimately drifts (.pnpmfile OPP-model resolution — the flag is + # its own ci.yaml/tag-release precedent); a no-op in lockfile-less + # wire-tools-ts. + run: pnpm install --ignore-scripts --no-frozen-lockfile + + - name: Update the @wireio/* dependencies + run: | + set -euo pipefail + node scripts/update-wireio-deps.mjs \ + --report-file update-output/report.md \ + --summary-file update-output/summary.json + + - name: Detect manifest changes + id: detect + run: | + set -euo pipefail + # Scoped to the manifests the script writes: in a lockfile repo the + # script-deps install may legitimately rewrite pnpm-lock.yaml, which + # must never turn a no-op run into changed=true. (git's default + # pathspec is fnmatch without FNM_PATHNAME, so '*package.json' + # matches at every depth, root included.) + if git diff --quiet -- '*package.json'; then + echo "changed=false" >> "$GITHUB_OUTPUT" + echo "Already current — nothing to open." + # A release-triggered run that changes NOTHING is suspicious by + # definition (possible npm propagation lag) — make it visible. + if [[ "$GITHUB_EVENT_NAME" == "repository_dispatch" ]]; then + echo "::warning::release-triggered run produced no updates — possible npm propagation lag; re-run shortly if a release just published" + fi + else + echo "changed=true" >> "$GITHUB_OUTPUT" + fi + + - name: Refresh the lockfile (lockfile repos only) + if: steps.detect.outputs.changed == 'true' + run: | + set -euo pipefail + # wire-libraries-ts COMMITS pnpm-lock.yaml; wire-tools-ts gitignores + # it — but an earlier install still CREATES one on disk there, so the + # test is trackedness, not existence. The guard keeps this workflow + # byte-identical in both repos. + if git ls-files --error-unmatch pnpm-lock.yaml >/dev/null 2>&1; then + pnpm install --lockfile-only --ignore-scripts --no-frozen-lockfile + fi + + - name: Gate — the updated ranges must install, build and test + if: steps.detect.outputs.changed == 'true' + env: + JEST_JUNIT_OUTPUT_DIR: reports/junit + JEST_JUNIT_OUTPUT_NAME: jest-junit.xml + run: | + set -euo pipefail + # The PR opened by open-pr will carry NO checks (bot-authored events + # do not trigger workflows in this repo), so THIS step is the gate — + # the same double-install + build + root-jest sequence ci.yaml and + # tag-release.yaml run, now resolving the UPDATED ranges. A red run + # here means the updated packages break this repo; no PR opens until + # that is fixed. (--no-frozen-lockfile: see the script-deps install + # note.) + pnpm install --no-frozen-lockfile + pnpm build + pnpm install --no-frozen-lockfile + pnpm run build + pnpm run test:ci + + - name: Upload the gate's test results + if: failure() + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: update-gate-junit + path: reports/junit/ + if-no-files-found: ignore + + - name: Upload the updated manifests and report + if: steps.detect.outputs.changed == 'true' + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: update-manifests + # Explicit paths, not **/package.json: the upload globber follows + # symlinks and would walk the pnpm node_modules forest before any + # exclude could filter it. Root package.json is load-bearing: it + # keeps the artifact's least-common-ancestor at the workspace root, + # so open-pr's download overlays every file back at its real path. + # examples/* and pnpm-lock.yaml match nothing in wire-tools-ts — + # harmless (if-no-files-found evaluates the TOTAL result set). + path: | + package.json + packages/*/package.json + examples/*/package.json + pnpm-lock.yaml + update-output/ + if-no-files-found: error + + open-pr: + name: Open the update PR + needs: update-and-gate + if: needs.update-and-gate.outputs.changed == 'true' + runs-on: ubuntu-latest + timeout-minutes: 10 + # The write scopes live ONLY here — this job runs no pnpm install and no + # third-party code beyond the pinned actions. (No `actions: read`: a + # same-run artifact download authenticates with the runner's scoped + # credential.) + permissions: + contents: write + pull-requests: write + env: + GH_TOKEN: ${{ github.token }} + # Untrusted trigger metadata → env var; rendered via quoted printf only. + REASON: ${{ github.event.client_payload.reason || inputs.reason }} + steps: + - name: Checkout + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + with: + # Same commit as update-and-gate (see its checkout comment). Every + # run pushes a NEW uniquely-named branch, so no history depth or + # remote-branch refs are needed. + ref: ${{ github.sha }} + + - name: Restore the updated manifests and report + # Pinned on the SAME v4 major as the upload (newer majors changed + # digest handling and client internals; this hand-off is unexercisable + # before merge, so it rides the guaranteed-compatible matched pair). + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 + with: + name: update-manifests + + - name: Open the update PR + run: | + set -euo pipefail + + updated_count="$(jq -r '.updated | length' update-output/summary.json)" + if [[ "$updated_count" -eq 0 ]]; then + echo "::error::summary reports zero updated packages but the tree changed" >&2 + exit 1 + fi + + # Branch: chore/update-wireio-deps--. + # Each updated package's unscoped name, truncated to 6 chars, + # deduped, joined; the whole suffix capped so branch names stay sane. + timestamp="$(date -u +%Y%m%d-%H%M%S)" + suffix="$(jq -r '.updated | keys | map(sub("^@wireio/"; "") | .[0:6]) | unique | join("-") | .[0:48] | sub("-+$"; "")' update-output/summary.json)" + branch="chore/update-wireio-deps-${timestamp}-${suffix}" + title="chore(deps): update @wireio/* (${updated_count} package(s))" + + # The reason is untrusted trigger metadata: flatten newlines, strip + # fence-breakers, cap the length, and render it inside a text fence — + # so issue-closing keywords, @-mentions, and markdown forgery are all + # inert. (printf's format string is a literal; %s carries the value.) + reason_safe="$(printf '%s' "${REASON:-manual dispatch (no reason given)}" \ + | tr -d "\`" | tr '\n\r' ' ' | cut -c1-500)" + { + cat update-output/report.md + printf "\n**Reason:**\n\n\`\`\`text\n%s\n\`\`\`\n" "$reason_safe" + printf '\n**Validation:** this run installed, built, and ran the root jest gate against these versions before opening the PR (%s/%s/actions/runs/%s). The PR itself shows no checks — bot-authored events do not trigger workflows in this repo.\n' \ + "$GITHUB_SERVER_URL" "$GITHUB_REPOSITORY" "$GITHUB_RUN_ID" + } > "$RUNNER_TEMP/pr-body.md" + + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git checkout -b "$branch" + # -u: stage every tracked modification — exactly the script's + # manifest writes plus (in a lockfile repo) the refreshed lockfile; + # update-output/ is untracked and stays out. + git add -u + if git diff --cached --quiet; then + echo "::notice::tree already matches — nothing to open" + exit 0 + fi + git commit -m "$title" + git push origin "$branch" + + # Decoupled: the PR landing and the reviewer request fail + # independently — a failed reviewer request (someone leaves the org) + # must not red a run whose PR already exists. + pr_url="$(gh pr create --base master --head "$branch" \ + --title "$title" --body-file "$RUNNER_TEMP/pr-body.md")" + gh pr edit "$pr_url" --add-reviewer jglanz --add-reviewer bearcubsvet \ + || echo "::warning::could not request reviewers on ${pr_url} — assign manually" + echo "Opened ${pr_url}" diff --git a/CLAUDE.md b/CLAUDE.md index 5aa5f5c..4cbb18a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -192,19 +192,29 @@ Every new/modified symbol ships unit tests in the same change. Tests never assum ## CI/CD -GitHub Actions uses a two-gate release flow: -- `prepare-release.yaml` is manually dispatched with a bump type. It bumps each - package on its own version track and opens a release-preparation PR; it never - pushes directly to `master` or publishes. -- After that PR is reviewed and merged, `tag-release.yaml` is manually - dispatched and pauses for approval in the `release` environment. -- The approved job installs the workspace, builds and tests, publishes all - non-private packages in dependency order, creates the annotated tag, and - creates the GitHub release. -- `workspace:*` dependencies become concrete current workspace versions at - publish time. Published package manifests must keep `repository.url` set to - `https://github.com/Wire-Network/wire-libraries-ts` so npm provenance matches - GitHub Actions source metadata. +GitHub Actions: +- `ci.yaml` — build + test gate on every PR and push to `master`. +- Releases use the two-gate flow (ported from wire-sysio / wire-cdt "Strategy C"): + 1. `prepare-release.yaml` (workflow_dispatch; bump = patch/minor/major/prerelease) + bumps every package on its own track and opens a `release/prep-v` PR — + and REFUSES to run while a previous release PR is still open. Nothing publishes + until a human merges the PR. + 2. `tag-release.yaml` — auto-triggered when a release PR merges (workflow_dispatch + remains the manual path; the `release` Environment's required reviewers are the + second gate). It reads the merged version from master, runs the build/test + gate, publishes all non-private packages to npm on the channel dist-tag (the + version suffix IS the channel; no suffix = `latest`), tags master, and + publishes the GitHub release. On stable releases it then mints a 1-hour + `wire-release-bot` App token (the App's ONE sanctioned use — see the manifest + rule) and fires a `repository_dispatch` to Wire-Network/wire-tools-ts, whose + `update-wireio-deps.yaml` opens a PR updating every `@wireio/*` range. +- `update-wireio-deps.yaml` (manual dispatch, optional `reason` input) — updates + every `@wireio/*` dependency THIS repo declares (e.g. `opp-typescript-models`, + `outpost-*-artifacts`) to its own npm latest via + `scripts/update-wireio-deps.mjs`, refreshes `pnpm-lock.yaml`, gates the result + with install+build+test, and opens a reviewed PR. +- Published package manifests must keep `repository.url` set to + `https://github.com/Wire-Network/wire-libraries-ts` — npm provenance matches it. ## Documentation Comments diff --git a/package.json b/package.json index 8da1100..7d29ccd 100644 --- a/package.json +++ b/package.json @@ -31,7 +31,8 @@ "ts-jest": "^29.4.6", "ts-node": "^10.9.2", "typescript": "^6.0.2", - "typescript-eslint": "^8.64.0" + "typescript-eslint": "^8.64.0", + "zx": "^8.8.5" }, "packageManager": "pnpm@10.34.5", "engines": { @@ -39,13 +40,23 @@ "browser": "last 5 versions" }, "dependencies": { - "@wireio/opp-typescript-models": "^1.0.26" + "@wireio/opp-typescript-models": "^1.0.48" }, "resolutions": { + "@3fv/prelude-ts": "^0.8.41", "@aws-sdk/client-firehose": "3.1102.0", "@aws-sdk/client-kms": "3.1102.0", "@aws-sdk/client-sns": "3.1102.0", "@aws-sdk/client-ssm": "3.1102.0", - "@aws-sdk/client-sts": "3.1102.0" + "@aws-sdk/client-sts": "3.1102.0", + "bluebird": "3.7.2", + "debug": "4.3.4", + "lodash": "4.18.1", + "prettier": "3.8.1", + "tracer": "1.3.0", + "webpack": "5.104.1", + "webpack-cli": "6.0.1", + "webpack-dev-server": "6.0.0", + "ws": "8.21.0" } } diff --git a/packages/sdk-core/package.json b/packages/sdk-core/package.json index 3063b60..0aae587 100644 --- a/packages/sdk-core/package.json +++ b/packages/sdk-core/package.json @@ -43,7 +43,7 @@ "@3fv/prelude-ts": "^0.8.41", "@noble/curves": "1.9.7", "@wireio/shared": "workspace:*", - "@wireio/opp-typescript-models": "^1.0.26", + "@wireio/opp-typescript-models": "^1.0.48", "@ethersproject/bignumber": "^5.8.0", "@ethersproject/bytes": "^5.8.0", "@ethersproject/hash": "^5.8.0", diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 8a8d855..89723f9 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -1,17 +1,3 @@ packages: - "packages/*" - "examples/*" -minimumReleaseAge: 1440 -minimumReleaseAgeExclude: - - "@wireio/*" -overrides: - '@3fv/prelude-ts': ^0.8.41 - bluebird: 3.7.2 - debug: 4.3.4 - lodash: 4.18.1 - prettier: 3.8.1 - tracer: 1.3.0 - webpack: 5.104.1 - webpack-cli: 6.0.1 - webpack-dev-server: 6.0.0 - ws: 8.21.0 diff --git a/scripts/update-wireio-deps.mjs b/scripts/update-wireio-deps.mjs new file mode 100755 index 0000000..49172c4 --- /dev/null +++ b/scripts/update-wireio-deps.mjs @@ -0,0 +1,379 @@ +#!/usr/bin/env node +/** + * Update every `@wireio/*` dependency in this monorepo to its own latest + * version on npm. + * + * Scope: EVERY `@wireio/*` package the repo declares — regardless of which + * repo publishes it (wire-libraries-ts, wire-sysio's opp model bundles, the + * outpost artifact packages, ...). Each dependency updates to ITS OWN + * `latest` dist-tag; versions are never cross-assigned between packages. + * `workspace:*` dependencies (this repo's own packages) are never touched. + * + * Coverage: every package.json outside node_modules / lib / dist / .git + * (root + workspace packages), across `dependencies`, `devDependencies`, + * and `resolutions`. Each declaration keeps its OWN range operator — only + * the version number moves. Complex ranges (anything but `^x.y.z` / + * `~x.y.z` / `x.y.z`) are skipped with a warning rather than guessed at. + * + * Branch- and environment-agnostic: the script operates on the WORKING TREE + * wherever it is invoked — by hand on any branch, or inside GHA — and WRITES + * the updates by default. `--dry-run` is the single no-write mode; it still + * prints what would change, in one of two formats (human-readable by default, + * one JSON document with `--json`). + * + * Usage: + * ./scripts/update-wireio-deps.mjs [options] + * + * Options: + * --dry-run Preview only — print what would change without + * writing any file; exits 2 when updates exist + * (a script-friendly drift signal) + * --json Machine-readable output: stdout carries ONE JSON + * document ({ dryRun, editCount, updated, edits, + * complexSkips, registrySkips }) instead of the + * human-readable report + * --report-file Write the markdown update report (PR-body fragment) + * --summary-file Write `{ "updated": { name: latest }, "editCount": n }` + * as JSON (the workflow derives the branch name from it) + * + * Examples: + * ./scripts/update-wireio-deps.mjs # update the working tree + * ./scripts/update-wireio-deps.mjs --dry-run # human-readable preview + * ./scripts/update-wireio-deps.mjs --dry-run --json | jq .updated + * ./scripts/update-wireio-deps.mjs \ + * --report-file update-output/report.md --summary-file update-output/summary.json + * + * Exit codes: + * 0 updates written, or already current (no-op) + * 1 error (registry failure, no @wireio/* dependencies declared, bad input) + * 2 --dry-run found at least one available update + */ + +import { fileURLToPath } from "node:url" +import { argv, chalk, echo, fs, glob, path, $ } from "zx" + +// --------------------------------------------------------------------------- +// Constants +// --------------------------------------------------------------------------- + +const scriptDir = path.dirname(fileURLToPath(import.meta.url)) +/** Repo root = one level up from scripts/ (the same file serves both repos). */ +const repoRoot = path.resolve(scriptDir, "..") + +/** Scope prefix every update candidate carries. */ +const WireScope = "@wireio/" +/** Dependency value prefix marking this repo's own workspace packages. */ +const WorkspaceProtocolPrefix = "workspace:" +/** + * package.json sections that may carry update candidates. `resolutions` + * covers the root policy-pin block (no `@wireio/*` entries exist there today; + * this future-proofs the field). + */ +const DependencyFields = ["dependencies", "devDependencies", "resolutions"] +/** + * The only range shapes this script rewrites: an optional `^` or `~` followed + * by a stable x.y.z. Group 1 is the preserved OPERATOR, group 2 the version. + */ +const SimpleRangePattern = /^([\^~]?)(\d+\.\d+\.\d+)$/ +/** Stable x.y.z shape a `latest` dist-tag must have to be applied. */ +const StableVersionPattern = /^\d+\.\d+\.\d+$/ +/** Manifest discovery glob and the trees it must never descend into. */ +const ManifestGlob = "**/package.json" +const ManifestIgnoreGlobs = ["**/node_modules/**", "**/lib/**", "**/dist/**", "**/.git/**"] +/** Indentation for rewritten package.json files (repo standard). */ +const ManifestJsonSpaces = 2 + +// --------------------------------------------------------------------------- +// Manifest discovery +// --------------------------------------------------------------------------- + +/** + * Discover every manifest that can declare `@wireio/*` dependencies — + * dynamically, so new workspace packages (and repos with different layouts, + * e.g. an `examples/*` glob) are covered with no list to maintain. + * + * @return {Promise>} loaded manifests + */ +async function discoverManifests() { + const files = await glob(ManifestGlob, { + cwd: repoRoot, + ignore: ManifestIgnoreGlobs, + absolute: true + }) + return Promise.all(files.sort().map(async file => ({ file, json: await fs.readJson(file) }))) +} + +/** + * Collect the distinct `@wireio/*` dependency names declared across the given + * manifests, excluding `workspace:*` values (this repo's own packages). + * + * @param {Array<{ file: string, json: object }>} manifests loaded manifests + * @return {string[]} sorted candidate package names + */ +function collectCandidateNames(manifests) { + const names = new Set() + manifests.forEach(({ json }) => + DependencyFields.forEach(field => + Object.entries(json[field] ?? {}).forEach(([name, range]) => { + if (name.startsWith(WireScope) && !String(range).startsWith(WorkspaceProtocolPrefix)) { + names.add(name) + } + }) + ) + ) + return [...names].sort() +} + +// --------------------------------------------------------------------------- +// Registry resolution +// --------------------------------------------------------------------------- + +/** + * Resolve one package's `latest` dist-tag from npm. + * + * @param {string} name npm package name + * @return {Promise<{ name: string, latest: string } | null>} the package name + * and its `latest` dist-tag — `null` when the package does not exist on the + * registry (E404); every other registry failure throws + */ +async function viewLatest(name) { + // --prefer-online: the registry CDN caches packuments for up to 300 s — a + // post-release run must not settle for a cached read. + const result = await $`npm view ${name} --json --prefer-online version dist-tags` + .nothrow() + .quiet() + if (result.exitCode !== 0) { + if (/\bE404\b/.test(result.stderr)) { + return null + } + throw new Error(`npm view ${name} failed (exit ${result.exitCode}): ${result.stderr.trim()}`) + } + // npm view --json wraps multi-field output in a one-element array on current + // npm majors (verified on npm 12 / Node 24 — the workflow's pin); older + // majors return the bare object — accept both shapes, and fail LOUDLY when + // neither yields a version. (A silent fallback here once misclassified every + // package in an earlier design; never default this.) + const parsed = JSON.parse(result.stdout) + const info = Array.isArray(parsed) ? parsed[0] : parsed + const latest = info?.["dist-tags"]?.latest ?? info?.version + if (latest == null) { + throw new Error(`npm view ${name}: no version in the --json payload — registry output shape changed?`) + } + return { name, latest } +} + +/** + * Resolve the latest stable version for every candidate. A 404 (not on the + * registry) and a non-stable `latest` dist-tag become skip RECORDS — the + * caller decides how to render them (human warnings, or fields of the --json + * document; nothing may print here or JSON stdout would be polluted). + * + * @param {string[]} candidateNames the locally-declared `@wireio/*` names + * @return {Promise<{ latestVersions: Map, registrySkips: Array<{ name: string, reason: string }> }>} + * the resolved package name → latest stable version map, plus the skips + */ +async function resolveLatestVersions(candidateNames) { + const latestVersions = new Map() + const registrySkips = [] + // Sequential on purpose: a handful of packages, and interleaved npm output + // would garble the log. + for (const name of candidateNames) { + const view = await viewLatest(name) + if (view == null) { + registrySkips.push({ name, reason: "not published on the registry" }) + continue + } + if (!StableVersionPattern.test(view.latest)) { + registrySkips.push({ name, reason: `latest dist-tag ${view.latest} is not a stable x.y.z` }) + continue + } + latestVersions.set(name, view.latest) + } + return { latestVersions, registrySkips } +} + +// --------------------------------------------------------------------------- +// Update +// --------------------------------------------------------------------------- + +/** + * Rewrite the latest versions into the loaded manifests (in memory), + * preserving each declaration's range operator; collect one edit record per + * changed declaration and one skip record per complex range left alone. + * + * @param {Array<{ file: string, json: object }>} manifests loaded manifests + * @param {Map} latestVersions package name → latest stable version + * @return {{ edits: Array<{ file: string, field: string, name: string, oldRange: string, newRange: string }>, + * complexSkips: Array<{ file: string, field: string, name: string, range: string }> }} + * the applied edits and the skipped complex-range declarations + */ +function updateManifests(manifests, latestVersions) { + const edits = [] + const complexSkips = [] + manifests.forEach(({ file, json }) => + DependencyFields.forEach(field => + Object.entries(json[field] ?? {}).forEach(([name, range]) => { + const latest = latestVersions.get(name) + if (latest == null || String(range).startsWith(WorkspaceProtocolPrefix)) { + return + } + const match = SimpleRangePattern.exec(String(range)) + if (match == null) { + complexSkips.push({ file, field, name, range: String(range) }) + return + } + const [, operator] = match + const newRange = `${operator}${latest}` + if (range !== newRange) { + json[field][name] = newRange + edits.push({ file, field, name, oldRange: String(range), newRange }) + } + }) + ) + ) + return { edits, complexSkips } +} + +/** + * Render the markdown update report — the PR-body fragment. + * + * @param {Array<{ name: string, oldRange: string, newRange: string }>} edits changed declarations + * @param {Array<{ file: string, name: string, range: string }>} complexSkips complex-range declarations left alone + * @return {string} markdown report + */ +function renderReport(edits, complexSkips) { + const header = "## Update `@wireio/*` dependencies to latest" + const skipLines = + complexSkips.length === 0 + ? [] + : [ + "", + "Left alone (complex ranges — update by hand if intended):", + ...complexSkips.map(skip => `- \`${skip.name}\` \`${skip.range}\` in \`${path.relative(repoRoot, skip.file)}\``) + ] + if (edits.length === 0) { + return [header, "", "Already current — no manifest changes.", ...skipLines, ""].join("\n") + } + const byName = new Map() + edits.forEach(edit => { + if (!byName.has(edit.name)) { + byName.set(edit.name, { oldRanges: new Set(), newRanges: new Set(), declarationCount: 0 }) + } + const row = byName.get(edit.name) + row.oldRanges.add(edit.oldRange) + row.newRanges.add(edit.newRange) + row.declarationCount += 1 + }) + const rows = [...byName.entries()] + .sort(([left], [right]) => left.localeCompare(right)) + .map( + ([name, { oldRanges, newRanges, declarationCount }]) => + `| \`${name}\` | ${[...oldRanges].sort().map(range => `\`${range}\``).join(", ")} | ${[...newRanges].sort().map(range => `\`${range}\``).join(", ")} | ${declarationCount} |` + ) + return [ + header, + "", + "Every `@wireio/*` dependency updates to ITS OWN npm `latest`; range operators", + "are preserved; `workspace:*` is untouched.", + "", + "| package | previous range(s) | updated to | declarations |", + "|---|---|---|---|", + ...rows, + ...skipLines, + "" + ].join("\n") +} + +// --------------------------------------------------------------------------- +// Main +// --------------------------------------------------------------------------- + +async function main() { + const dryRun = argv["dry-run"] === true + const jsonOutput = argv.json === true + const reportFile = argv["report-file"] + const summaryFile = argv["summary-file"] + + const manifests = await discoverManifests() + const candidateNames = collectCandidateNames(manifests) + if (candidateNames.length === 0) { + throw new Error("no @wireio/* dependencies declared in any manifest — nothing to update") + } + if (!jsonOutput) { + echo(`candidates: ${candidateNames.join(", ")}`) + } + + const { latestVersions, registrySkips } = await resolveLatestVersions(candidateNames) + if (latestVersions.size === 0) { + throw new Error("no @wireio/* candidate resolved a stable latest version from the registry") + } + if (!jsonOutput) { + registrySkips.forEach(skip => echo(chalk.yellow(`skip ${skip.name} — ${skip.reason}`))) + echo(`latest: ${[...latestVersions.entries()].map(([name, latest]) => `${name}@${latest}`).join(", ")}`) + } + + const { edits, complexSkips } = updateManifests(manifests, latestVersions) + const report = renderReport(edits, complexSkips) + + const updated = Object.fromEntries( + [...new Set(edits.map(edit => edit.name))].sort().map(name => [name, latestVersions.get(name)]) + ) + if (summaryFile != null) { + // outputJson (not writeJson): the workflow points this into a not-yet- + // existing update-output/ directory, and outputJson creates parent dirs. + await fs.outputJson(String(summaryFile), { updated, editCount: edits.length }, { spaces: ManifestJsonSpaces }) + } + if (reportFile != null) { + await fs.outputFile(String(reportFile), report) + } + + if (jsonOutput) { + // --json: stdout carries exactly ONE machine-readable document — nothing + // else may print on stdout in this mode, or piped `jq` consumers break. + const relativeEdits = edits.map(edit => ({ ...edit, file: path.relative(repoRoot, edit.file) })) + const relativeSkips = complexSkips.map(skip => ({ ...skip, file: path.relative(repoRoot, skip.file) })) + echo( + JSON.stringify( + { dryRun, editCount: edits.length, updated, edits: relativeEdits, complexSkips: relativeSkips, registrySkips }, + null, + ManifestJsonSpaces + ) + ) + } else { + echo(report) + } + + if (edits.length === 0) { + if (!jsonOutput) { + echo(chalk.green("already current — nothing to write")) + } + return + } + if (dryRun) { + if (!jsonOutput) { + echo(chalk.yellow(`--dry-run: ${edits.length} declaration(s) would update — not writing`)) + } + // exitCode, not process.exit(): exit() can truncate still-flushing stdout + // on CI pipes — the output above must always land. Exit 2 = updates are + // available (a script-friendly drift signal). + process.exitCode = 2 + return + } + + const changedManifests = manifests.filter(({ file }) => edits.some(edit => edit.file === file)) + await Promise.all( + changedManifests.map(({ file, json }) => fs.writeJson(file, json, { spaces: ManifestJsonSpaces })) + ) + if (!jsonOutput) { + echo(chalk.green(`updated ${edits.length} declaration(s) across ${changedManifests.length} manifest(s)`)) + } +} + +main().catch(error => { + // console.error (the CLI-script carve-out): errors go to stderr so a piped + // --json stdout stays parseable; consumers check the exit code first. + console.error(chalk.red(`update-wireio-deps: ${error.message}`)) + // exitCode, not process.exit(): let stdout/stderr drain so the error above + // is never truncated on CI pipes. + process.exitCode = 1 +})