From 69f1b6a49c465256a0409facba9395705b7f4911 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Torsten=20St=C3=BCber?= <15174476+TorstenStueber@users.noreply.github.com> Date: Sun, 13 Sep 2026 20:46:48 -0300 Subject: [PATCH 1/2] ci: guard release consistency at merge time The release commit's versions are computed from the changesets present when `version-packages` ran, and nothing rechecked that set before the merge landed. A changeset merging in between produced a bump that did not describe it, caught only by the publish that runs after the merge is already permanent. `Release guard` holds the invariant instead: a change that bumps a published package version must leave `.changeset/` empty. It keys on the bump rather than the commit subject so it holds in every event context, and it runs in the merge queue, where it sees the merge result rather than the branch. `Changeset guard` requires a changeset from any pull request touching the sources a published artifact is built from, with a `no-changeset` label for changes that ship nothing, so a released fix cannot arrive undescribed and at a level some other change picked. `Registry drift` compares every published manifest against npm daily and opens an issue when the registry does not serve a declared version, since a release commit merges before the publish it describes is attempted and nothing reverts it when that publish does not finish. --- .github/workflows/ci.yml | 109 ++++++++++++++++++++++++++- .github/workflows/registry-drift.yml | 93 +++++++++++++++++++++++ docs/RELEASE_PROCESS.md | 23 ++++++ 3 files changed, 224 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/registry-drift.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1b014f844..dc010ec19 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -649,6 +649,111 @@ jobs: path: playground/playwright-report retention-days: 14 + release-guard: + name: Release guard + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-depth: 0 + persist-credentials: false + + # A release commit states the versions it publishes, and those versions + # are computed from the changesets present when `version-packages` ran. + # A changeset that merged after that moment is not in the computation, so + # publishing would ship a version whose number and changelog do not + # describe everything in it. + # + # The invariant is checked against the merge result rather than the + # branch, which is the whole point: on a pull request the checkout is the + # merge commit, and in a merge group it is the queued stack. A release + # branch that went stale while it waited therefore fails here, before the + # bump reaches the default branch, rather than in the publish that runs + # after the merge is already permanent. + # + # Keyed on the version bump rather than on the commit subject, so it + # holds in every event context and cannot be stepped around by writing a + # different title. + - name: A version bump must consume every changeset + run: | + set -euo pipefail + + case "${{ github.event_name }}" in + pull_request) base="HEAD^1" ;; + merge_group) base="${{ github.event.merge_group.base_sha }}" ;; + *) base="HEAD^" ;; + esac + + bumped=() + for manifest in js/packages/*/package.json; do + if [ "$(jq -r '.private // false' "${manifest}")" = "true" ]; then + continue + fi + head_version="$(jq -r '.version' "${manifest}")" + base_version="$(git show "${base}:${manifest}" 2>/dev/null | jq -r '.version' 2>/dev/null || true)" + if [ -n "${base_version}" ] && [ "${base_version}" != "${head_version}" ]; then + bumped+=("$(jq -r '.name' "${manifest}") ${base_version} -> ${head_version}") + fi + done + + if [ "${#bumped[@]}" -eq 0 ]; then + echo "No published package version changed; nothing to guard." + exit 0 + fi + + printf 'Version bump in this change:\n' + printf ' %s\n' "${bumped[@]}" + + remaining_changesets="$(find .changeset -maxdepth 1 -type f -name '*.md' -print)" + if [ -n "${remaining_changesets}" ]; then + echo "::error::A changeset merged after these versions were computed, so the bump does not describe it. Rebuild the release commit from the default branch: reset to it, then rerun \`npm run version-packages\` and \`scripts/cut-version.sh\`. Rebasing and rerunning compounds the bump instead of replacing it." + echo "${remaining_changesets}" + exit 1 + fi + + echo "Every changeset is consumed by this bump." + + changeset-guard: + name: Changeset guard + # Release pull requests consume changesets and add none, and a change that + # genuinely ships nothing opts out with the `no-changeset` label rather + # than by saying nothing. + if: >- + github.event_name == 'pull_request' && + !startsWith(github.event.pull_request.title, 'release:') && + !contains(github.event.pull_request.labels.*.name, 'no-changeset') + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-depth: 2 + persist-credentials: false + + # Version numbers and changelogs are computed from changeset files, so a + # change that reaches a published artifact without one is released at + # whatever level an unrelated change happened to pick, and is described + # nowhere a consumer reads. The paths below are the sources the published + # npm packages and the prebuilt CLI are built from. + - name: Released code needs a changeset + run: | + set -euo pipefail + + changed="$(git diff --name-only HEAD^1 HEAD)" + released='^(rust/crates/truapi(-server|-platform|-provider|-macros|-host-cli)?/|js/packages/[^/]+/src/)' + + if ! grep -qE "${released}" <<<"${changed}"; then + echo "No published sources touched." + exit 0 + fi + + if grep -qE '^\.changeset/[^/]+\.md$' <<<"${changed}"; then + echo "Changeset present." + exit 0 + fi + + echo "::error::This change reaches a published artifact but adds no changeset, so it would ship undescribed and at a version level chosen by some other change. Run \`npm run changeset\`, or add the \`no-changeset\` label if it genuinely ships nothing." + exit 1 + ci-status: name: CI Status if: always() @@ -674,6 +779,8 @@ jobs: playground, explorer, e2e, + release-guard, + changeset-guard, ] steps: # The results come from the needs context itself rather than a second list @@ -701,7 +808,7 @@ jobs: REQUIRED: >- rust wasm-provider licenses codegen ios-bindings changes ios-swift android-bindings ts-client ts-host ts-debugger playground explorer - e2e + e2e release-guard changeset-guard RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} SHA: ${{ github.event.pull_request.head.sha || github.sha }} run: | diff --git a/.github/workflows/registry-drift.yml b/.github/workflows/registry-drift.yml new file mode 100644 index 000000000..ba5fbddff --- /dev/null +++ b/.github/workflows/registry-drift.yml @@ -0,0 +1,93 @@ +name: Registry drift + +# A release commit lands on the default branch before the publish it describes +# is attempted, and nothing reverts it when that publish does not finish. The +# repository then advertises versions the registry has never served, which +# looks exactly like a completed release from the inside: manifests, changelogs +# and tags all agree, and only npm disagrees. This compares the two on a +# schedule so the gap surfaces on its own rather than when a consumer hits it. + +on: + schedule: + - cron: "17 7 * * *" + workflow_dispatch: + +permissions: + contents: read + +jobs: + drift: + name: Manifests versus registry + runs-on: ubuntu-latest + permissions: + contents: read + issues: write + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: "22" + + - name: Compare every published manifest against npm + id: compare + run: | + set -euo pipefail + + missing=() + for manifest in js/packages/*/package.json; do + if [ "$(jq -r '.private // false' "${manifest}")" = "true" ]; then + continue + fi + name="$(jq -r '.name' "${manifest}")" + version="$(jq -r '.version' "${manifest}")" + + if npm view "${name}@${version}" version >/dev/null 2>&1; then + echo "${name}@${version} is on npm." + else + latest="$(npm view "${name}" version 2>/dev/null || echo 'unknown')" + echo "${name}@${version} is NOT on npm (latest published: ${latest})." + missing+=("${name}: this branch says ${version}, npm serves ${latest}") + fi + done + + if [ "${#missing[@]}" -eq 0 ]; then + echo "drift=false" >> "$GITHUB_OUTPUT" + exit 0 + fi + + { + echo "drift=true" + echo "report<> "$GITHUB_OUTPUT" + + - name: Report the drift + if: steps.compare.outputs.drift == 'true' + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + REPORT: ${{ steps.compare.outputs.report }} + RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + TITLE: "Release drift: published versions do not match the default branch" + run: | + set -euo pipefail + + # One issue for as long as the condition holds: a daily run that + # opened a new one every morning would be noise, and noise is how the + # previous silence happened. + existing="$(gh issue list --state open --search "${TITLE} in:title" --json number,title \ + --jq "[.[] | select(.title == \"${TITLE}\")] | .[0].number // empty")" + + body="$(printf 'The default branch declares package versions the registry does not serve.\n\n```\n%s\n```\n\nA release commit is already merged, so the repository reads as released while consumers still install the previous version. Check the most recent `Release` run for where the publish stopped.\n\n%s\n' "${REPORT}" "${RUN_URL}")" + + if [ -n "${existing}" ]; then + gh issue comment "${existing}" --body "${body}" + else + gh issue create --title "${TITLE}" --body "${body}" + fi + + echo "::error::Published versions do not match the default branch." + exit 1 diff --git a/docs/RELEASE_PROCESS.md b/docs/RELEASE_PROCESS.md index d23aa8077..04e674c94 100644 --- a/docs/RELEASE_PROCESS.md +++ b/docs/RELEASE_PROCESS.md @@ -20,6 +20,12 @@ npm run changeset # interactive: pick patch / minor / major + a short npm run version-packages # consumes the changeset, bumps package.json + writes CHANGELOG.md ``` +A change that reaches a published artifact carries its own changeset, added by +the pull request that makes it: the `Changeset guard` job in CI fails a pull +request that touches the sources the npm packages or the prebuilt CLI are built +from without adding one. A change that genuinely ships nothing says so with the +`no-changeset` label rather than by saying nothing at all. + The first command writes a markdown file under `.changeset/`; the second consumes it, bumps the selected package `package.json`, appends the package `CHANGELOG.md`, deletes the changeset file, and then runs @@ -251,6 +257,23 @@ has to be one of ours rather than a personal one. - A `release:` PR with mismatched `js/packages/truapi/package.json` and `rust/crates/truapi/Cargo.toml` versions is blocked at PR time by the `Release version check` workflow. +- A release commit publishes the versions its changesets computed, so those + changesets have to be the ones present when it merges. The `Release guard` job + in CI holds that: whenever a change bumps a published package version, + `.changeset/` must be empty. It runs in the merge queue as well as on the pull + request, so it sees the merge result rather than the branch, and a release + branch that went stale while it waited fails before the bump reaches `main`. +- Rebuild a stale release branch by resetting it to `main` and running + `npm run version-packages` and `scripts/cut-version.sh` again. Rebasing and + rerunning does not work: `version-packages` consumes the changeset files it + reads and bumps from whatever version the manifests currently hold, so a + second run on top of the first compounds the bump and writes a changelog entry + for a version nobody publishes. +- `Registry drift` compares every published manifest version against npm on a + daily schedule and opens an issue when the registry does not serve one. A + release commit is merged before the publish it describes is attempted and + nothing reverts it, so the repository can otherwise read as released while + consumers still install the previous version. - Publishing uses the default `GITHUB_TOKEN`. The only other credentials are the org-level `NPM_PUBLISH_AUTOMATION_TOKEN` that the automation itself relies on, and the notification app's client id and private key, which are read by the From fd693fe32acf7ad71c6bc492b2471e1ed46a377f Mon Sep 17 00:00:00 2001 From: pgherveou Date: Mon, 14 Sep 2026 04:58:07 +0000 Subject: [PATCH 2/2] ci: harden release consistency guards after review --- .github/registry-drift-exceptions.json | 1 + .github/workflows/ci.yml | 108 +++-- .github/workflows/registry-drift.yml | 59 ++- .github/workflows/release-version-check.yml | 6 +- .github/workflows/release.yml | 4 +- CLAUDE.md | 14 +- README.md | 5 + docs/RELEASE_PROCESS.md | 47 +- scripts/lib/release-consistency.test.mjs | 474 ++++++++++++++++++++ 9 files changed, 638 insertions(+), 80 deletions(-) create mode 100644 .github/registry-drift-exceptions.json create mode 100644 scripts/lib/release-consistency.test.mjs diff --git a/.github/registry-drift-exceptions.json b/.github/registry-drift-exceptions.json new file mode 100644 index 000000000..0967ef424 --- /dev/null +++ b/.github/registry-drift-exceptions.json @@ -0,0 +1 @@ +{} diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index dc010ec19..2d7b6ce66 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -198,6 +198,8 @@ jobs: outputs: sdk_swift: ${{ steps.filter.outputs.sdk_swift }} sdk_kotlin: ${{ steps.filter.outputs.sdk_kotlin }} + needs_changeset: ${{ steps.filter.outputs.needs_changeset }} + adds_changeset: ${{ steps.filter.outputs.adds_changeset }} steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: @@ -221,10 +223,12 @@ jobs: # exercises the jobs that filter gates. - name: Detect which areas changed id: filter + env: + EVENT_NAME: ${{ github.event_name }} run: | set -euo pipefail - if [ "${{ github.event_name }}" != "pull_request" ]; then + if [ "$EVENT_NAME" != "pull_request" ]; then { echo "sdk_swift=true" echo "sdk_kotlin=true" @@ -244,6 +248,16 @@ jobs: gate sdk_swift '^(ios/|Package\.swift$|Makefile$|Cargo\.toml$|Cargo\.lock$|js/container/|scripts/codegen\.sh$|rust/crates/truapi/|rust/crates/truapi-codegen/|rust/crates/truapi-macros/|rust/crates/truapi-platform/|rust/crates/truapi-server/|rust/crates/truapi-provider/|rust/crates/uniffi-bindgen-cli/|\.github/workflows/ci\.yml$)' gate sdk_kotlin '^(android/|Makefile$|Cargo\.toml$|Cargo\.lock$|build\.gradle\.kts$|settings\.gradle\.kts$|gradle\.properties$|package\.json$|package-lock\.json$|scripts/codegen\.sh$|rust/crates/truapi/|rust/crates/truapi-codegen/|rust/crates/truapi-macros/|rust/crates/truapi-platform/|rust/crates/truapi-server/|rust/crates/uniffi-bindgen-cli/|\.github/workflows/ci\.yml$)' + # Include generators, dependency pins and package build configuration: + # these can change a published artifact without touching its sources. + gate needs_changeset '^(rust/crates/truapi(-server|-platform|-provider|-macros|-host-cli|-codegen)?/|js/packages/[^/]+/(src/|scripts/|package\.json$|tsconfig[^/]*\.json$)|Cargo\.(toml|lock)$|package(-lock)?\.json$|rust-toolchain(\.toml)?$|\.cargo/|Makefile$|scripts/(codegen\.sh|bundle-truapi-dts\.mjs|regen-explorer-versions\.mjs)$)' + + # Deleting, editing or renaming an old changeset does not declare a + # new change. README.md is Changesets' documentation, not a release. + changed="$(git diff --name-only --find-renames --diff-filter=A HEAD^1 HEAD -- .changeset/)" + changed="$(sed '\|^\.changeset/README\.md$|d' <<<"$changed")" + gate adds_changeset '^\.changeset/[^/]+\.md$' + ios-swift: name: iOS package (swift compile) needs: [changes, codegen] @@ -412,7 +426,7 @@ jobs: with: bun-version: latest - # Node builtins only, so this needs neither the codegen output nor npm ci. + # No npm dependencies, so this needs neither the codegen output nor npm ci. - name: Test scripts run: npm run test:scripts @@ -658,41 +672,49 @@ jobs: fetch-depth: 0 persist-credentials: false - # A release commit states the versions it publishes, and those versions - # are computed from the changesets present when `version-packages` ran. - # A changeset that merged after that moment is not in the computation, so - # publishing would ship a version whose number and changelog do not - # describe everything in it. - # - # The invariant is checked against the merge result rather than the - # branch, which is the whole point: on a pull request the checkout is the - # merge commit, and in a merge group it is the queued stack. A release - # branch that went stale while it waited therefore fails here, before the - # bump reaches the default branch, rather than in the publish that runs - # after the merge is already permanent. - # - # Keyed on the version bump rather than on the commit subject, so it - # holds in every event context and cannot be stepped around by writing a - # different title. + # Check the merge result, including a queued stack, for unconsumed changes + # whenever a published version changes. The PR title cannot bypass this. - name: A version bump must consume every changeset + env: + EVENT_NAME: ${{ github.event_name }} + MERGE_BASE: ${{ github.event.merge_group.base_sha }} + PUSH_BASE: ${{ github.event.before }} run: | set -euo pipefail - case "${{ github.event_name }}" in + case "$EVENT_NAME" in pull_request) base="HEAD^1" ;; - merge_group) base="${{ github.event.merge_group.base_sha }}" ;; + merge_group) base="$MERGE_BASE" ;; + push) base="$PUSH_BASE" ;; *) base="HEAD^" ;; esac + git rev-parse --verify "${base}^{commit}" >/dev/null + + # Match by package name so moving a manifest cannot hide its bump. + base_paths="$(git ls-tree -r --name-only "$base" -- js/packages/)" + base_packages='[]' + while IFS= read -r manifest; do + if [[ ! "$manifest" =~ ^js/packages/[^/]+/package\.json$ ]]; then + continue + fi + package="$(git show "${base}:${manifest}")" + if [ "$(jq -r '.private // false' <<<"$package")" = true ]; then + continue + fi + package="$(jq -ce '{name, version} | if (.name | type == "string" and length > 0) and (.version | type == "string" and length > 0) then . else error("Invalid package manifest") end' <<<"$package")" + base_packages="$(jq -c --argjson package "$package" '. + [$package]' <<<"$base_packages")" + done <<<"$base_paths" bumped=() for manifest in js/packages/*/package.json; do if [ "$(jq -r '.private // false' "${manifest}")" = "true" ]; then continue fi - head_version="$(jq -r '.version' "${manifest}")" - base_version="$(git show "${base}:${manifest}" 2>/dev/null | jq -r '.version' 2>/dev/null || true)" + name="$(jq -er '.name | select(type == "string" and length > 0)' "$manifest")" + head_version="$(jq -er '.version | select(type == "string" and length > 0)' "$manifest")" + base_version="$(jq -r --arg name "$name" '.[] | select(.name == $name) | .version' <<<"$base_packages")" if [ -n "${base_version}" ] && [ "${base_version}" != "${head_version}" ]; then - bumped+=("$(jq -r '.name' "${manifest}") ${base_version} -> ${head_version}") + bumped+=("${name} ${base_version} -> ${head_version}") fi done @@ -704,7 +726,7 @@ jobs: printf 'Version bump in this change:\n' printf ' %s\n' "${bumped[@]}" - remaining_changesets="$(find .changeset -maxdepth 1 -type f -name '*.md' -print)" + remaining_changesets="$(find .changeset -maxdepth 1 -type f -name '*.md' ! -name README.md -print)" if [ -n "${remaining_changesets}" ]; then echo "::error::A changeset merged after these versions were computed, so the bump does not describe it. Rebuild the release commit from the default branch: reset to it, then rerun \`npm run version-packages\` and \`scripts/cut-version.sh\`. Rebasing and rerunning compounds the bump instead of replacing it." echo "${remaining_changesets}" @@ -715,38 +737,30 @@ jobs: changeset-guard: name: Changeset guard - # Release pull requests consume changesets and add none, and a change that - # genuinely ships nothing opts out with the `no-changeset` label rather - # than by saying nothing. - if: >- - github.event_name == 'pull_request' && - !startsWith(github.event.pull_request.title, 'release:') && - !contains(github.event.pull_request.labels.*.name, 'no-changeset') + needs: changes + if: github.event_name == 'pull_request' && needs.changes.outputs.needs_changeset == 'true' runs-on: ubuntu-latest + permissions: + pull-requests: read steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - fetch-depth: 2 - persist-credentials: false - - # Version numbers and changelogs are computed from changeset files, so a - # change that reaches a published artifact without one is released at - # whatever level an unrelated change happened to pick, and is described - # nowhere a consumer reads. The paths below are the sources the published - # npm packages and the prebuilt CLI are built from. + # Read live metadata so "Re-run failed jobs" sees a label or title edit + # without restarting the compile jobs on every metadata change. - name: Released code needs a changeset + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GH_REPO: ${{ github.repository }} + PR_NUMBER: ${{ github.event.pull_request.number }} + ADDS_CHANGESET: ${{ needs.changes.outputs.adds_changeset }} run: | set -euo pipefail - changed="$(git diff --name-only HEAD^1 HEAD)" - released='^(rust/crates/truapi(-server|-platform|-provider|-macros|-host-cli)?/|js/packages/[^/]+/src/)' - - if ! grep -qE "${released}" <<<"${changed}"; then - echo "No published sources touched." + pr="$(gh pr view "$PR_NUMBER" --json title,labels)" + if jq -e '(.title | startswith("release:")) or any(.labels[]; .name == "no-changeset")' <<<"$pr" >/dev/null; then + echo "Release PR or explicit no-changeset opt-out." exit 0 fi - if grep -qE '^\.changeset/[^/]+\.md$' <<<"${changed}"; then + if [ "$ADDS_CHANGESET" = true ]; then echo "Changeset present." exit 0 fi diff --git a/.github/workflows/registry-drift.yml b/.github/workflows/registry-drift.yml index ba5fbddff..27ec6bcfe 100644 --- a/.github/workflows/registry-drift.yml +++ b/.github/workflows/registry-drift.yml @@ -1,11 +1,7 @@ name: Registry drift -# A release commit lands on the default branch before the publish it describes -# is attempted, and nothing reverts it when that publish does not finish. The -# repository then advertises versions the registry has never served, which -# looks exactly like a completed release from the inside: manifests, changelogs -# and tags all agree, and only npm disagrees. This compares the two on a -# schedule so the gap surfaces on its own rather than when a consumer hits it. +# Manifests can advance even when publishing fails. Tags and GitHub Releases +# already wait for npm confirmation; this check covers the default branch. on: schedule: @@ -15,6 +11,10 @@ on: permissions: contents: read +concurrency: + group: registry-drift + cancel-in-progress: false + jobs: drift: name: Manifests versus registry @@ -25,6 +25,7 @@ jobs: steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: + ref: ${{ github.event.repository.default_branch }} persist-credentials: false - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 @@ -36,20 +37,32 @@ jobs: run: | set -euo pipefail + exceptions="$(cat .github/registry-drift-exceptions.json)" + jq -e 'type == "object" and all(.[]; type == "string" and test("\\S"))' <<<"$exceptions" >/dev/null + missing=() for manifest in js/packages/*/package.json; do if [ "$(jq -r '.private // false' "${manifest}")" = "true" ]; then continue fi - name="$(jq -r '.name' "${manifest}")" - version="$(jq -r '.version' "${manifest}")" + name="$(jq -er '.name | select(type == "string" and length > 0)' "$manifest")" + version="$(jq -er '.version | select(type == "string" and length > 0)' "$manifest")" + reason="$(jq -r --arg target "${name}@${version}" '.[$target] // empty' <<<"$exceptions")" + if [ -n "$reason" ]; then + echo "${name}@${version} is intentionally unpublished: ${reason}" + continue + fi - if npm view "${name}@${version}" version >/dev/null 2>&1; then + if result="$(npm view "${name}@${version}" version --json --fetch-retries=2 --fetch-timeout=10000 2>"${RUNNER_TEMP}/npm-view-error.log")"; then echo "${name}@${version} is on npm." + elif [ "$(jq -r '.error.code // empty' <<<"$result" 2>/dev/null)" = E404 ]; then + echo "${name}@${version} is NOT on npm." + missing+=("${name}@${version}") else - latest="$(npm view "${name}" version 2>/dev/null || echo 'unknown')" - echo "${name}@${version} is NOT on npm (latest published: ${latest})." - missing+=("${name}: this branch says ${version}, npm serves ${latest}") + cat "${RUNNER_TEMP}/npm-view-error.log" >&2 + echo "$result" >&2 + echo "::error::Could not query npm for ${name}@${version}; no drift issue will be changed." + exit 1 fi done @@ -69,24 +82,28 @@ jobs: if: steps.compare.outputs.drift == 'true' env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GH_REPO: ${{ github.repository }} REPORT: ${{ steps.compare.outputs.report }} - RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + WORKFLOW_URL: ${{ github.server_url }}/${{ github.repository }}/actions/workflows/registry-drift.yml TITLE: "Release drift: published versions do not match the default branch" run: | set -euo pipefail - # One issue for as long as the condition holds: a daily run that - # opened a new one every morning would be noise, and noise is how the - # previous silence happened. - existing="$(gh issue list --state open --search "${TITLE} in:title" --json number,title \ - --jq "[.[] | select(.title == \"${TITLE}\")] | .[0].number // empty")" + # Paginate the issue listing without interpreting the title as search + # syntax. Keep one report and avoid daily duplicate comments. + issues="$(gh api --paginate "repos/${GH_REPO}/issues?state=open&per_page=100")" + existing="$(jq -sr --arg title "$TITLE" 'add | map(select(.pull_request == null and .title == $title)) | .[0].number // empty' <<<"$issues")" - body="$(printf 'The default branch declares package versions the registry does not serve.\n\n```\n%s\n```\n\nA release commit is already merged, so the repository reads as released while consumers still install the previous version. Check the most recent `Release` run for where the publish stopped.\n\n%s\n' "${REPORT}" "${RUN_URL}")" + body_file="${RUNNER_TEMP}/registry-drift.md" + printf 'The default branch declares package versions the registry does not serve.\n\n```\n%s\n```\n\nCheck the most recent `Release` run for a failed publish or an omitted target. Document deliberately unpublished versions in `.github/registry-drift-exceptions.json` with a reason.\n\n[Registry drift runs](%s)\n' "$REPORT" "$WORKFLOW_URL" > "$body_file" if [ -n "${existing}" ]; then - gh issue comment "${existing}" --body "${body}" + current_body="$(jq -sr --argjson number "$existing" 'add | .[] | select(.number == $number) | .body' <<<"$issues")" + if [ "$current_body" != "$(cat "$body_file")" ]; then + gh issue edit "$existing" --body-file "$body_file" + fi else - gh issue create --title "${TITLE}" --body "${body}" + gh issue create --title "${TITLE}" --body-file "$body_file" fi echo "::error::Published versions do not match the default branch." diff --git a/.github/workflows/release-version-check.yml b/.github/workflows/release-version-check.yml index 2b2eb1372..379df484d 100644 --- a/.github/workflows/release-version-check.yml +++ b/.github/workflows/release-version-check.yml @@ -20,11 +20,13 @@ jobs: run: npm run check-release-versions - name: Verify changesets were consumed + # Keep this title-based check for release PRs that do not bump npm + # versions, including retries and releases of native artifacts only. run: | set -euo pipefail - remaining_changesets="$(find .changeset -maxdepth 1 -type f -name '*.md' -print)" + remaining_changesets="$(find .changeset -maxdepth 1 -type f -name '*.md' ! -name README.md -print)" if [ -n "${remaining_changesets}" ]; then - echo "::error::Release PR still contains unconsumed changesets. Run npm run version-packages." + echo "::error::Release PR still contains unconsumed changesets. Rebuild the release commit from the default branch: reset to it, then rerun \`npm run version-packages\` and \`scripts/cut-version.sh\`. Rebasing and rerunning compounds the bump instead of replacing it." echo "${remaining_changesets}" exit 1 fi diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index c6ba1d921..9637739b4 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -216,9 +216,9 @@ jobs: run: | set -euo pipefail npm run check-release-versions - remaining_changesets="$(find .changeset -maxdepth 1 -type f -name '*.md' -print)" + remaining_changesets="$(find .changeset -maxdepth 1 -type f -name '*.md' ! -name README.md -print)" if [ -n "${remaining_changesets}" ]; then - echo "::error::Release commit still contains unconsumed changesets. Run npm run version-packages." + echo "::error::Release commit still contains unconsumed changesets. Open a new release PR from the default branch, then run \`npm run version-packages\` and \`scripts/cut-version.sh\`. Do not reset the merged default branch or rerun versioning on top of an unmerged release bump." echo "${remaining_changesets}" exit 1 fi diff --git a/CLAUDE.md b/CLAUDE.md index 5279099f0..a08b4c59f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -59,6 +59,8 @@ scripts/battery.sh run the generated battery against both headless CLI h scripts/truapi-host-installer.sh one-liner installer for the prebuilt truapi-host CLI .github/consumers.json maps each released package to the repos notified by a bump issue +.github/registry-drift-exceptions.json + documents intentionally unpublished npm package versions ``` ### Crate + binding invariants @@ -121,12 +123,20 @@ scripts/truapi-host-installer.sh the same class of drift; `make android-check` does it locally. The embedding apps are compiled by neither. - Both compile gates are path-filtered from one place. The `changes` job in - `ci.yml` computes `sdk_swift` and `sdk_kotlin`, and each gated job reads the - output. Because neither binding set is committed, a filter has to name every + `ci.yml` computes `sdk_swift`, `sdk_kotlin`, `needs_changeset`, and + `adds_changeset`, and each gated job reads the output. Because neither + binding set is committed, a filter has to name every crate its bindings are generated from, since a protocol change leaves no `ios/` or `android/` diff to key on. Every job in `ci.yml` is aggregated by `ci-status`, which is the check worth requiring: a job skipped by its filter counts as a pass, so a gate cannot stall a PR it does not apply to. + `Changeset guard` reads live PR titles and labels, so re-running failed jobs + picks up a `no-changeset` opt-out. `Release guard` rejects npm version changes + with unconsumed changesets on the merge result, including in the merge queue. + `registry-drift.yml` checks default-branch manifests against npm daily and + maintains one issue; explicit package-version exceptions live in + `.github/registry-drift-exceptions.json`. See `docs/RELEASE_PROCESS.md` for + label setup and release recovery. Hosts implement `HostBridge`, whose protocol extension defaults the optional callbacks; `TrUAPIHostRuntime` and each product execution retain one. To publish, include `@parity/ios-host ` in the `release:` PR title. diff --git a/README.md b/README.md index a6a340bc6..4bae6d81f 100644 --- a/README.md +++ b/README.md @@ -345,6 +345,11 @@ Android host artifacts alongside them. A release also opens a bump issue on each repository listed in [`.github/consumers.json`](.github/consumers.json) that pins one of the published packages. +CI requires changesets for published build inputs and rejects version bumps +with unconsumed changesets, including in the merge queue. The daily +`Registry drift` workflow reports manifest versions missing from npm; see the +release guide for opt-outs and recovery. + ## Contributing See [`CONTRIBUTING.md`](CONTRIBUTING.md) for issue reports, feature proposals, and the RFC process. diff --git a/docs/RELEASE_PROCESS.md b/docs/RELEASE_PROCESS.md index 04e674c94..dad97b528 100644 --- a/docs/RELEASE_PROCESS.md +++ b/docs/RELEASE_PROCESS.md @@ -23,8 +23,19 @@ npm run version-packages # consumes the changeset, bumps package.json + writ A change that reaches a published artifact carries its own changeset, added by the pull request that makes it: the `Changeset guard` job in CI fails a pull request that touches the sources the npm packages or the prebuilt CLI are built -from without adding one. A change that genuinely ships nothing says so with the -`no-changeset` label rather than by saying nothing at all. +from without adding one. This includes code generators, dependency manifests +and lockfiles, and package build scripts and configuration. Deleting, editing +or renaming an existing changeset, or adding `.changeset/README.md`, does not +satisfy the guard. + +A change that ships nothing can use the `no-changeset` label. After adding the +label or correcting a title to `release:`, select **Re-run failed jobs** on CI; +the guard reads the current PR metadata, so no new commit or full CI run is +needed. Repository maintainers must create the label once before using it: + +```bash +gh label create no-changeset --color d4c5f9 --description "No published artifact changes; changeset not required" +``` The first command writes a markdown file under `.changeset/`; the second consumes it, bumps the selected package `package.json`, appends the package @@ -73,6 +84,14 @@ release: @parity/truapi 0.5.0, @parity/ios-host 0.5.0, @parity/android-host 0.1. Separate multiple package/version targets with commas. The workflow validates each declared version against its package manifest and publishes every target whose version is not already on npm in the same automation run. +Include every npm package whose version was bumped, including dependent +packages bumped by Changesets. If a version is deliberately left unpublished, +record that exact `package@version` and a reason in +[`.github/registry-drift-exceptions.json`](../.github/registry-drift-exceptions.json). +For example, an entry could be +`"@parity/truapi-debugger@0.1.2": "Deferred until the standalone debugger release"`. +An exception applies only to that version; the next bump is checked normally. +The exception list is empty by default and does not control publishing. ### 4. Get the PR reviewed and merged @@ -84,6 +103,12 @@ prefix will silently skip the publish. If that does happen, open a follow-up `release:` PR with any trivial change (a CHANGELOG note tweak, say); the tag-already-exists guard makes re-runs safe. +The merge queue checks the release against the queued merge result, so there +is no required "rebase immediately before enqueueing" step. A changeset ahead +of or grouped with the release can still reject the group. Rebuild the release +from current `main` as described below, then enqueue it again; other PRs in a +rejected group may also need to be requeued. + ### 5. Watch the publish On merge, CI runs as usual. When CI passes, the `Release` workflow: @@ -257,11 +282,17 @@ has to be one of ours rather than a personal one. - A `release:` PR with mismatched `js/packages/truapi/package.json` and `rust/crates/truapi/Cargo.toml` versions is blocked at PR time by the `Release version check` workflow. +- `Release version check` also requires consumed changesets for a `release:` + PR without an npm version bump, including a publish retry or native-only + release. It remains alongside `Release guard`, which detects bumps without + relying on the title and also runs in the merge queue. Both checks give the + same recovery guidance for an unmerged release branch. - A release commit publishes the versions its changesets computed, so those changesets have to be the ones present when it merges. The `Release guard` job in CI holds that: whenever a change bumps a published package version, - `.changeset/` must be empty. It runs in the merge queue as well as on the pull - request, so it sees the merge result rather than the branch, and a release + `.changeset/` must contain no release markdown files (`README.md` is ignored). + It runs in the merge queue as well as on the pull request, so it sees the + merge result rather than the branch, and a release branch that went stale while it waited fails before the bump reaches `main`. - Rebuild a stale release branch by resetting it to `main` and running `npm run version-packages` and `scripts/cut-version.sh` again. Rebasing and @@ -270,8 +301,12 @@ has to be one of ours rather than a personal one. second run on top of the first compounds the bump and writes a changelog entry for a version nobody publishes. - `Registry drift` compares every published manifest version against npm on a - daily schedule and opens an issue when the registry does not serve one. A - release commit is merged before the publish it describes is attempted and + daily schedule, excluding documented exceptions for specific versions, and + opens an issue when the registry does not serve one. Manual runs also check + the default branch. It updates one open issue when the report changes and + leaves an identical report alone. Close the issue once resolved; there is + no daily comment loop. Registry lookup errors fail the job without filing or + updating an issue. A release commit is merged before the publish it describes is attempted and nothing reverts it, so the repository can otherwise read as released while consumers still install the previous version. - Publishing uses the default `GITHUB_TOKEN`. The only other credentials are the diff --git a/scripts/lib/release-consistency.test.mjs b/scripts/lib/release-consistency.test.mjs new file mode 100644 index 000000000..a722b22e4 --- /dev/null +++ b/scripts/lib/release-consistency.test.mjs @@ -0,0 +1,474 @@ +import assert from "node:assert/strict"; +import { execFileSync, spawnSync } from "node:child_process"; +import { + mkdirSync, + mkdtempSync, + readFileSync, + renameSync, + rmSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import test from "node:test"; + +// Execute the workflow's actual shell steps against small Git histories and +// stubbed external commands. No GitHub writes or registry requests leave tests. +function step(workflow, name) { + const yaml = readFileSync( + new URL(`../../.github/workflows/${workflow}.yml`, import.meta.url), + "utf8", + ); + const start = yaml.indexOf(` - name: ${name}\n`); + assert.notEqual(start, -1, `step ${name} exists`); + const run = yaml.indexOf(" run: |\n", start); + assert.notEqual(run, -1, `step ${name} has a script`); + return yaml + .slice(run + " run: |\n".length) + .match(/^(?: {10}[^\n]*\n|\n)+/)[0] + .replace(/^ {10}/gm, ""); +} + +const filter = step("ci", "Detect which areas changed"); +const release = step("ci", "A version bump must consume every changeset"); +const changeset = step("ci", "Released code needs a changeset"); +const compare = step( + "registry-drift", + "Compare every published manifest against npm", +); +const report = step("registry-drift", "Report the drift"); +const title = + "Release drift: published versions do not match the default branch"; +const manifest = "js/packages/truapi/package.json"; + +function fixture(t, { pending = false } = {}) { + const root = mkdtempSync(join(tmpdir(), "release-consistency-")); + t.after(() => rmSync(root, { recursive: true, force: true })); + const cwd = join(root, "repo"); + const bin = join(root, "bin"); + mkdirSync(cwd); + mkdirSync(bin); + const write = (path, contents) => { + const target = join(cwd, path); + mkdirSync(dirname(target), { recursive: true }); + writeFileSync(target, contents); + }; + const git = (...args) => + execFileSync("git", args, { + cwd, + encoding: "utf8", + stdio: ["ignore", "pipe", "pipe"], + }).trim(); + const commit = () => { + git("add", "-A"); + git( + "-c", + "user.name=Test", + "-c", + "user.email=test@example.com", + "-c", + "commit.gpgsign=false", + "commit", + "-qm", + "fixture", + "--allow-empty", + ); + return git("rev-parse", "HEAD"); + }; + const version = (value) => + write(manifest, JSON.stringify({ name: "@parity/truapi", version: value })); + git("init", "-q"); + version("1.0.0"); + write(".changeset/README.md", "Changesets documentation\n"); + write(".github/registry-drift-exceptions.json", "{}\n"); + if (pending) + write( + ".changeset/pending.md", + '---\n"@parity/truapi": patch\n---\nFix a bug.\n', + ); + const base = commit(); + const stub = (command, source) => + writeFileSync(join(bin, command), `#!/usr/bin/env node\n${source}`, { + mode: 0o755, + }); + // Every external command fails closed unless a test supplies its behavior. + stub("gh", 'throw new Error("unexpected GitHub call");'); + stub("npm", 'throw new Error("unexpected registry call");'); + const execute = (script, env = {}) => { + const output = join(root, "output"); + writeFileSync(output, ""); + const result = spawnSync("bash", ["-c", script], { + cwd, + encoding: "utf8", + env: { + ...process.env, + PATH: `${bin}:${process.env.PATH}`, + GITHUB_OUTPUT: output, + RUNNER_TEMP: root, + EVENT_NAME: "pull_request", + MERGE_BASE: base, + PUSH_BASE: base, + GH_REPO: "test/repository", + PR_NUMBER: "747", + ADDS_CHANGESET: "false", + TITLE: title, + REPORT: "@parity/truapi@1.0.0", + WORKFLOW_URL: "https://example.com/registry-drift", + ...env, + }, + }); + return { ...result, output: readFileSync(output, "utf8") }; + }; + return { root, cwd, write, git, commit, version, base, stub, execute }; +} + +function passed(result) { + assert.equal(result.status, 0, result.stdout + result.stderr); +} + +for (const path of [ + "rust/crates/truapi-codegen/src/emitter.rs", + "rust/crates/truapi-host-cli/src/main.rs", + "scripts/codegen.sh", + "scripts/bundle-truapi-dts.mjs", + "scripts/regen-explorer-versions.mjs", + "Cargo.toml", + "Cargo.lock", + "package.json", + "package-lock.json", + "js/packages/truapi-host/package.json", + "js/packages/truapi-host/scripts/build-wasm.mjs", + "js/packages/truapi/tsconfig.json", + "js/packages/truapi/src/client.ts", +]) { + test(`changeset required for ${path}`, (t) => { + const f = fixture(t); + f.write(path, "changed\n"); + f.commit(); + const result = f.execute(filter); + passed(result); + assert.match(result.output, /^needs_changeset=true$/m); + assert.match(result.output, /^adds_changeset=false$/m); + }); +} + +test("documentation changes do not require a changeset", (t) => { + const f = fixture(t); + f.write("docs/RELEASE_PROCESS.md", "new docs\n"); + f.commit(); + const result = f.execute(filter); + passed(result); + assert.match(result.output, /^needs_changeset=false$/m); +}); + +for (const operation of ["add", "delete", "edit", "rename", "readme"]) { + test(`changeset evidence: ${operation}`, (t) => { + const f = fixture(t, { pending: true }); + const pending = join(f.cwd, ".changeset/pending.md"); + if (operation === "add") + f.write( + ".changeset/new.md", + '---\n"@parity/truapi": minor\n---\nA new feature.\n', + ); + if (operation === "delete") rmSync(pending); + if (operation === "edit") f.write(".changeset/pending.md", "edited\n"); + if (operation === "rename") + renameSync(pending, join(f.cwd, ".changeset/renamed.md")); + if (operation === "readme") { + rmSync(join(f.cwd, ".changeset/README.md")); + f.commit(); + f.write(".changeset/README.md", "documentation\n"); + } + f.commit(); + const result = f.execute(filter); + passed(result); + assert.match( + result.output, + new RegExp(`^adds_changeset=${operation === "add"}$`, "m"), + ); + }); +} + +test("a rerun reads changed PR metadata and does not use a stale opt-out", (t) => { + const f = fixture(t); + const metadata = join(f.root, "pr.json"); + f.stub( + "gh", + `process.stdout.write(require("node:fs").readFileSync(${JSON.stringify(metadata)}));`, + ); + for (const [pr, status] of [ + [{ title: "fix: behavior", labels: [] }, 1], + [{ title: "fix: behavior", labels: [{ name: "no-changeset" }] }, 0], + [{ title: "release: @parity/truapi 1.0.0", labels: [] }, 0], + [{ title: "fix: behavior", labels: [] }, 1], + ]) { + writeFileSync(metadata, JSON.stringify(pr)); + const result = f.execute(changeset); + assert.equal(result.status, status, result.stdout + result.stderr); + } + passed(f.execute(changeset, { ADDS_CHANGESET: "true" })); +}); + +test("PR metadata lookup errors fail the guard", (t) => { + const f = fixture(t); + f.stub("gh", "process.exit(1);"); + assert.notEqual(f.execute(changeset).status, 0); +}); + +for (const event of [ + "pull_request", + "merge_group", + "push", + "workflow_dispatch", +]) { + test(`${event} rejects a bump with a pending changeset`, (t) => { + const f = fixture(t, { pending: true }); + f.version("1.1.0"); + f.commit(); + const result = f.execute(release, { EVENT_NAME: event }); + assert.equal(result.status, 1, result.stderr); + assert.match(result.stdout, /Rebuild the release commit/); + }); +} + +test("a release that passes on its branch fails after merging a late changeset", (t) => { + const f = fixture(t, { pending: true }); + f.git("checkout", "-qb", "release"); + f.version("1.1.0"); + rmSync(join(f.cwd, ".changeset/pending.md")); + f.commit(); + passed(f.execute(release)); + + f.git("checkout", "-qb", "updated-base", f.base); + f.write( + ".changeset/late.md", + '---\n"@parity/truapi": patch\n---\nLate fix.\n', + ); + const updatedBase = f.commit(); + f.git( + "-c", + "user.name=Test", + "-c", + "user.email=test@example.com", + "-c", + "commit.gpgsign=false", + "merge", + "--no-ff", + "release", + "-m", + "queued merge", + ); + for (const event of ["pull_request", "merge_group"]) { + const result = f.execute(release, { + EVENT_NAME: event, + MERGE_BASE: updatedBase, + }); + assert.equal(result.status, 1, result.stderr); + assert.match(result.stdout, /\.changeset\/late\.md/); + } +}); + +test("a consumed release passes with Changesets' README still present", (t) => { + const f = fixture(t, { pending: true }); + f.version("1.1.0"); + rmSync(join(f.cwd, ".changeset/pending.md")); + f.commit(); + passed(f.execute(release)); +}); + +test("a source change without a bump may leave pending changesets", (t) => { + const f = fixture(t, { pending: true }); + f.write("rust/crates/truapi/src/lib.rs", "new code\n"); + f.commit(); + passed(f.execute(release)); +}); + +test("unknown merge bases and missing shallow parents fail closed", (t) => { + const f = fixture(t); + assert.notEqual( + f.execute(release, { EVENT_NAME: "merge_group", MERGE_BASE: "missing" }) + .status, + 0, + ); + assert.notEqual(f.execute(release).status, 0); +}); + +test("malformed base manifests fail closed", (t) => { + const f = fixture(t); + f.write(manifest, "{broken"); + f.commit(); + f.version("1.1.0"); + f.commit(); + assert.notEqual(f.execute(release).status, 0); +}); + +test("a moved manifest still triggers the release guard", (t) => { + const f = fixture(t, { pending: true }); + f.version("1.1.0"); + renameSync( + join(f.cwd, "js/packages/truapi"), + join(f.cwd, "js/packages/renamed"), + ); + f.commit(); + const result = f.execute(release); + assert.equal(result.status, 1, result.stderr); + assert.match(result.stdout, /@parity\/truapi 1.0.0 -> 1.1.0/); +}); + +test("new and private packages do not look like an existing release bump", (t) => { + const f = fixture(t, { pending: true }); + f.write( + "js/packages/new/package.json", + '{"name":"@parity/new","version":"0.1.0"}', + ); + f.write("js/packages/private/package.json", '{"private":true}'); + f.commit(); + passed(f.execute(release)); +}); + +test("a multi-commit push is compared against the pre-push revision", (t) => { + const f = fixture(t, { pending: true }); + f.version("1.1.0"); + f.commit(); + f.write("README.md", "second commit\n"); + f.commit(); + assert.equal(f.execute(release, { EVENT_NAME: "push" }).status, 1); +}); + +for (const response of ["published", "E404", "E500", "ETIMEDOUT", "invalid"]) { + test(`registry result: ${response}`, (t) => { + const f = fixture(t); + f.stub( + "npm", + ` + console.error("npm diagnostic on stderr"); + console.log(${JSON.stringify(response === "published" ? '"1.0.0"' : response === "invalid" ? "unparseable response" : JSON.stringify({ error: { code: response } }))}); + process.exit(${response === "published" ? 0 : 1}); + `, + ); + const result = f.execute(compare); + if (response === "published" || response === "E404") { + passed(result); + assert.match( + result.output, + new RegExp(`^drift=${response === "E404"}$`, "m"), + ); + } else { + assert.equal(result.status, 1); + assert.equal(result.output, ""); + } + }); +} + +test("a registry error after a missing version never emits a partial drift report", (t) => { + const f = fixture(t); + f.write( + "js/packages/a/package.json", + '{"name":"@parity/a","version":"1.0.0"}', + ); + f.stub( + "npm", + 'console.log(JSON.stringify({error:{code: process.argv[3].startsWith("@parity/a@") ? "E404" : "E500"}})); process.exit(1);', + ); + const result = f.execute(compare); + assert.equal(result.status, 1); + assert.equal(result.output, ""); +}); + +test("intentional omissions apply only to the documented package version", (t) => { + const f = fixture(t); + f.write( + ".github/registry-drift-exceptions.json", + '{"@parity/truapi@1.0.0":"Deferred release"}', + ); + passed(f.execute(compare)); + f.version("1.1.0"); + f.stub( + "npm", + 'console.log(JSON.stringify({error:{code:"E404"}})); process.exit(1);', + ); + const result = f.execute(compare); + passed(result); + assert.match(result.output, /drift=true/); + assert.match(result.output, /@parity\/truapi@1.1.0/); +}); + +test("exceptions require an explanation", (t) => { + const f = fixture(t); + f.write( + ".github/registry-drift-exceptions.json", + '{"@parity/truapi@1.0.0":" "}', + ); + assert.equal(f.execute(compare).status, 1); +}); + +test("private packages are not queried against npm", (t) => { + const f = fixture(t); + f.write(manifest, '{"private":true}'); + const result = f.execute(compare); + passed(result); + assert.match(result.output, /drift=false/); +}); + +test("drift reporting finds later pages and avoids duplicate issues and comments", (t) => { + const f = fixture(t); + const calls = join(f.root, "calls.jsonl"); + const pages = join(f.root, "issues.json"); + f.stub( + "gh", + ` + const fs = require("node:fs"); + const args = process.argv.slice(2); + fs.appendFileSync(${JSON.stringify(calls)}, JSON.stringify(args) + "\\n"); + if (args[0] === "api") process.stdout.write(fs.readFileSync(${JSON.stringify(pages)})); + else if (args[0] !== "issue" || !["edit", "create"].includes(args[1])) throw new Error("unexpected write"); + `, + ); + const listed = [ + { number: 2, title, pull_request: {} }, + { number: 3, title: `${title} elsewhere` }, + ]; + writeFileSync( + pages, + `${JSON.stringify(listed)}\n${JSON.stringify([{ number: 747, title, body: "outdated report" }])}`, + ); + assert.equal(f.execute(report).status, 1); + let requests = readFileSync(calls, "utf8").trim().split("\n").map(JSON.parse); + assert.deepEqual(requests[0], [ + "api", + "--paginate", + "repos/test/repository/issues?state=open&per_page=100", + ]); + assert.deepEqual(requests[1].slice(0, 3), ["issue", "edit", "747"]); + assert.equal(requests[1][3], "--body-file"); + const body = readFileSync(requests[1][4], "utf8"); + assert.match(body, /```\n@parity\/truapi@1.0.0\n```/); + + writeFileSync(calls, ""); + writeFileSync(pages, JSON.stringify([{ number: 747, title, body }])); + assert.equal(f.execute(report).status, 1); + requests = readFileSync(calls, "utf8").trim().split("\n").map(JSON.parse); + assert.equal(requests.length, 1, "unchanged report makes no writes"); + + writeFileSync(calls, ""); + writeFileSync(pages, JSON.stringify(listed)); + assert.equal(f.execute(report).status, 1); + requests = readFileSync(calls, "utf8").trim().split("\n").map(JSON.parse); + assert.deepEqual(requests[1].slice(0, 4), [ + "issue", + "create", + "--title", + title, + ]); +}); + +test("issue listing errors do not create a duplicate", (t) => { + const f = fixture(t); + const calls = join(f.root, "calls"); + f.stub( + "gh", + `require("node:fs").appendFileSync(${JSON.stringify(calls)}, process.argv[2] + "\\n"); process.exit(1);`, + ); + assert.equal(f.execute(report).status, 1); + assert.equal(readFileSync(calls, "utf8"), "api\n"); +});