Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/registry-drift-exceptions.json
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{}
127 changes: 124 additions & 3 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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"
Expand All @@ -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]
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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."
Comment thread
pgherveou marked this conversation as resolved.
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()
Expand All @@ -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
Expand Down Expand Up @@ -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: |
Expand Down
110 changes: 110 additions & 0 deletions .github/workflows/registry-drift.yml
Original file line number Diff line number Diff line change
@@ -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<<EOF"
printf '%s\n' "${missing[@]}"
echo "EOF"
} >> "$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
6 changes: 4 additions & 2 deletions .github/workflows/release-version-check.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
4 changes: 2 additions & 2 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
14 changes: 12 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 <version>` in the `release:` PR title.
Expand Down
5 changes: 5 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Loading
Loading