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 1b014f844..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 @@ -649,6 +663,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 + + # 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 "$EVENT_NAME" in + pull_request) base="HEAD^1" ;; + 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 + 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+=("${name} ${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' ! -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}" + exit 1 + fi + + echo "Every changeset is consumed by this bump." + + changeset-guard: + name: Changeset guard + needs: changes + if: github.event_name == 'pull_request' && needs.changes.outputs.needs_changeset == 'true' + runs-on: ubuntu-latest + permissions: + pull-requests: read + steps: + # 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 + + 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 [ "$ADDS_CHANGESET" = true ]; 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 +793,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 +822,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..27ec6bcfe --- /dev/null +++ b/.github/workflows/registry-drift.yml @@ -0,0 +1,110 @@ +name: Registry drift + +# Manifests can advance even when publishing fails. Tags and GitHub Releases +# already wait for npm confirmation; this check covers the default branch. + +on: + schedule: + - cron: "17 7 * * *" + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: registry-drift + cancel-in-progress: false + +jobs: + drift: + name: Manifests versus registry + runs-on: ubuntu-latest + permissions: + contents: read + issues: write + 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 + with: + node-version: "22" + + - name: Compare every published manifest against npm + id: compare + 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 -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 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 + 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 + + 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 }} + GH_REPO: ${{ github.repository }} + REPORT: ${{ steps.compare.outputs.report }} + 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 + + # 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_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 + 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-file "$body_file" + fi + + echo "::error::Published versions do not match the default branch." + exit 1 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 d23aa8077..dad97b528 100644 --- a/docs/RELEASE_PROCESS.md +++ b/docs/RELEASE_PROCESS.md @@ -20,6 +20,23 @@ 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. 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 `CHANGELOG.md`, deletes the changeset file, and then runs @@ -67,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 @@ -78,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: @@ -251,6 +282,33 @@ 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 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 + 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, 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 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 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"); +});