diff --git a/.github/workflows/package-publish.yml b/.github/workflows/package-publish.yml index fc5f7905b..45f1b4dd8 100644 --- a/.github/workflows/package-publish.yml +++ b/.github/workflows/package-publish.yml @@ -1,6 +1,9 @@ name: package-publish on: + pull_request: + types: [closed] + branches: [main] workflow_dispatch: inputs: npm_dist_tag: @@ -12,55 +15,194 @@ on: - next - latest only_workspace: - # A failed package cannot be retried by re-running the whole release: - # the 39 that already published reject with E403 and the run goes red - # before proving anything about the one that matters. description: "Publish only this workspace, e.g. @onekeyfe/react-native-bundle-crypto. Leave empty to publish every package." required: false default: "" type: string +permissions: + contents: read + +concurrency: + group: app-modules-package-publish + # Supported on GitHub.com; keep pending publish runs instead of replacing them. + # https://docs.github.com/en/actions/reference/workflows-and-actions/workflow-syntax#concurrency + queue: max + jobs: + notice-fork-merge: + if: >- + github.event_name == 'pull_request' && + github.event.pull_request.merged == true && + github.event.pull_request.head.repo.full_name != github.repository + runs-on: ubuntu-latest + steps: + - name: Explain manual release requirement + run: | + echo 'A merged fork PR does not trigger an automatic package release. Run package-publish manually when ready.' >> "$GITHUB_STEP_SUMMARY" + package-publish: + if: >- + github.event_name == 'workflow_dispatch' || + (github.event.pull_request.merged == true && + github.event.pull_request.head.repo.full_name == github.repository) runs-on: ubuntu-latest + outputs: + dist_tag: ${{ steps.channel.outputs.dist_tag }} + env: + ONLY_WORKSPACE: ${{ inputs.only_workspace || '' }} steps: - uses: actions/checkout@v6 + with: + ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.merge_commit_sha || github.sha }} + fetch-depth: 0 + persist-credentials: false - uses: actions/setup-node@v6 with: node-version: "24.x" registry-url: "https://registry.npmjs.org" - - name: Validate npm dist-tag + - name: Select release channel + id: channel + env: + EVENT_NAME: ${{ github.event_name }} + MANUAL_DIST_TAG: ${{ inputs.npm_dist_tag || '' }} + PR_BASE_SHA: ${{ github.event.pull_request.base.sha || '' }} + PR_HEAD_SHA: ${{ github.event.pull_request.head.sha || '' }} + run: | + if test "$EVENT_NAME" = 'workflow_dispatch'; then + dist_tag="$MANUAL_DIST_TAG" + else + dist_tag=$(node scripts/detect-release-channel.mjs "$PR_BASE_SHA" "$PR_HEAD_SHA") + fi + echo "dist_tag=$dist_tag" >> "$GITHUB_OUTPUT" + echo "Selected npm dist-tag: $dist_tag" >> "$GITHUB_STEP_SUMMARY" + - name: Install and test release tooling + run: | + corepack enable + yarn install --immutable + node --test scripts/*.test.mjs + - name: Prepare automatic preview version + if: github.event_name == 'pull_request' && steps.channel.outputs.dist_tag == 'next' + env: + PREVIEW_NUMBER: ${{ github.run_number }} + run: | + preview_version=$(node scripts/prepare-preview.mjs "$PREVIEW_NUMBER") + YARN_ENABLE_IMMUTABLE_INSTALLS=false yarn install + node scripts/validate-npm-dist-tag.mjs next + git diff --check + echo "Publishing preview $preview_version with the next tag" >> "$GITHUB_STEP_SUMMARY" + - name: Validate release version and dist-tag env: - NPM_DIST_TAG: ${{ inputs.npm_dist_tag }} + NPM_DIST_TAG: ${{ steps.channel.outputs.dist_tag }} run: node scripts/validate-npm-dist-tag.mjs "$NPM_DIST_TAG" - - name: Install Package - run: corepack enable && yarn install - - name: Publish packages (4 concurrent) - if: inputs.only_workspace == '' + - name: Publish missing packages after merge + if: github.event_name == 'pull_request' env: NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} - NPM_DIST_TAG: ${{ inputs.npm_dist_tag }} - run: yarn version:publish --tag "$NPM_DIST_TAG" - - name: Publish NativeList after shared build dependencies - if: inputs.only_workspace == '' + NPM_DIST_TAG: ${{ steps.channel.outputs.dist_tag }} + run: node scripts/publish-missing.mjs "$NPM_DIST_TAG" + - name: Publish missing packages manually + if: github.event_name == 'workflow_dispatch' && inputs.only_workspace == '' env: NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} - NPM_DIST_TAG: ${{ inputs.npm_dist_tag }} - run: yarn workspace @onekeyfe/react-native-native-list release --tag "$NPM_DIST_TAG" - - name: Publish a single workspace - if: inputs.only_workspace != '' + NPM_DIST_TAG: ${{ steps.channel.outputs.dist_tag }} + run: node scripts/publish-missing.mjs "$NPM_DIST_TAG" + - name: Publish a single workspace manually + if: github.event_name == 'workflow_dispatch' && inputs.only_workspace != '' env: NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} - NPM_DIST_TAG: ${{ inputs.npm_dist_tag }} - ONLY_WORKSPACE: ${{ inputs.only_workspace }} + NPM_DIST_TAG: ${{ steps.channel.outputs.dist_tag }} run: yarn workspace "$ONLY_WORKSPACE" release --tag "$NPM_DIST_TAG" - # npm reports success the moment it accepts a tarball, which is not the - # same as the version becoming available. 3.0.137 went out green with - # @onekeyfe/react-native-bundle-crypto staged but never committed: the - # version was undownloadable AND unrepublishable, and nothing in this - # workflow noticed. Fail the run that produced it instead. - name: Verify the published versions are on the registry env: - NPM_DIST_TAG: ${{ inputs.npm_dist_tag }} - ONLY_WORKSPACE: ${{ inputs.only_workspace }} + NPM_DIST_TAG: ${{ steps.channel.outputs.dist_tag }} run: node scripts/verify-published.mjs "$NPM_DIST_TAG" "$ONLY_WORKSPACE" + + sync-app-monorepo: + needs: package-publish + if: >- + github.event_name == 'pull_request' && + needs.package-publish.outputs.dist_tag == 'latest' + runs-on: ubuntu-latest + steps: + - name: Create read-only app-monorepo token + id: read-token + uses: actions/create-github-app-token@v3 + with: + app-id: ${{ vars.APP_RELEASE_APP_ID }} + private-key: ${{ secrets.APP_RELEASE_PRIVATE_KEY }} + owner: ${{ github.repository_owner }} + repositories: app-monorepo + permission-contents: read + - uses: actions/checkout@v6 + with: + ref: ${{ github.event.pull_request.merge_commit_sha }} + path: app-modules + persist-credentials: false + - uses: actions/checkout@v6 + with: + repository: OneKeyHQ/app-monorepo + ref: x + path: app-monorepo + token: ${{ steps.read-token.outputs.token }} + persist-credentials: false + - uses: actions/setup-node@v6 + with: + node-version: '24.x' + - name: Update only app-modules mobile dependencies + id: dependencies + working-directory: app-monorepo + run: | + node ../app-modules/scripts/sync-app-monorepo.mjs . + if git diff --quiet -- apps/mobile/package.json packages/components/package.json package.json; then + echo 'changed=false' >> "$GITHUB_OUTPUT" + else + echo 'changed=true' >> "$GITHUB_OUTPUT" + fi + - name: Refresh lockfile and check module registry + if: steps.dependencies.outputs.changed == 'true' + working-directory: app-monorepo + run: | + corepack enable + YARN_ENABLE_IMMUTABLE_INSTALLS=false yarn install --mode=skip-build + yarn install --immutable --mode=skip-build + yarn workspace @onekeyhq/mobile module-id:check + git diff --check + - name: Create app-monorepo write token + if: steps.dependencies.outputs.changed == 'true' + id: write-token + uses: actions/create-github-app-token@v3 + with: + app-id: ${{ vars.APP_RELEASE_APP_ID }} + private-key: ${{ secrets.APP_RELEASE_PRIVATE_KEY }} + owner: ${{ github.repository_owner }} + repositories: app-monorepo + permission-contents: write + permission-pull-requests: write + - name: Create or update app-monorepo PR + if: steps.dependencies.outputs.changed == 'true' + working-directory: app-monorepo + env: + GH_TOKEN: ${{ steps.write-token.outputs.token }} + run: | + version=$(node -p "require('../app-modules/native-views/react-native-native-list/package.json').version") + branch="codex/app-modules-v${version}" + git config user.name 'onekey-release[bot]' + git config user.email 'onekey-release[bot]@users.noreply.github.com' + git add -u apps/mobile/package.json packages/components/package.json package.json yarn.lock + git diff --cached --check + git commit -m "chore: update app-modules to ${version}" + gh auth setup-git + remote_sha=$(git ls-remote --heads origin "refs/heads/${branch}" | cut -f1) + if test -n "$remote_sha"; then + git push --force-with-lease="refs/heads/${branch}:${remote_sha}" origin "HEAD:refs/heads/${branch}" + else + git push origin "HEAD:refs/heads/${branch}" + fi + body="Update app-modules mobile dependencies to ${version} after all 41 packages passed npm registry verification. Source: ${GITHUB_REPOSITORY}@${GITHUB_SHA}. Module-ID registry structure is checked; new native runtime modules require a fresh Union Build map before registry update." + pr_number=$(gh pr list --repo OneKeyHQ/app-monorepo --state open --base x --head "$branch" --json number,headRepositoryOwner --jq "[.[] | select(.headRepositoryOwner.login == \"$GITHUB_REPOSITORY_OWNER\")][0].number") + if test -n "$pr_number"; then + gh pr edit "$pr_number" --repo OneKeyHQ/app-monorepo --title "chore: update app-modules to ${version}" --body "$body" + else + gh pr create --repo OneKeyHQ/app-monorepo --base x --head "$branch" --title "chore: update app-modules to ${version}" --body "$body" + fi diff --git a/README.md b/README.md index d70bb6832..a0c625e0a 100644 --- a/README.md +++ b/README.md @@ -13,14 +13,12 @@ yarn package:setup new-lib ``` -## Publish all package +## Release automation -To update the versions of all workspace packages, run the following command in the project root directory: +Every merged same-repository PR is classified by whether it changes a publishable workspace version. A change without version updates gets an ephemeral next-patch `-alpha.` version across all publishable packages and is published with the npm `next` tag; those preview versions are not committed back to `main` and do not update app-monorepo. A change that updates package versions publishes the exact synchronized stable version from the PR with the npm `latest` tag, without another automatic version or changelog change. After registry verification, a latest release opens an app-monorepo PR against `x` for the mobile, components, root dependency pins, and lockfile updates. Source PR approval is therefore also release approval when that PR changes versions. -```shell -yarn version:bump -yarn version:apply -``` -Commit version changes and push to GitHub. +Merged fork PRs are intentionally skipped with a workflow summary notice; run `package-publish` manually when ready. The manual action remains available for `next`, `latest`, and single-workspace recovery. + +Before enabling the workflow, install a GitHub App on `app-monorepo` with repository Contents (write) and Pull requests (write) permissions. Set repository variable `APP_RELEASE_APP_ID` and secret `APP_RELEASE_PRIVATE_KEY` in `app-modules`, and retain the existing `NPM_TOKEN` secret for npm publishing. The app-monorepo PR is never merged automatically. -Run publish package actions on GitHub. \ No newline at end of file +The app-monorepo job checks the existing module-ID registry but does not regenerate it: `module-id:update` requires a fresh Union Build module-ID map, which is unavailable in a clean dependency-update job. If a release adds native runtime modules, generate that map and update the registry during app-monorepo PR validation before merging it. diff --git a/scripts/detect-release-channel.mjs b/scripts/detect-release-channel.mjs new file mode 100644 index 000000000..7ad14e027 --- /dev/null +++ b/scripts/detect-release-channel.mjs @@ -0,0 +1,73 @@ +import { execFileSync } from "node:child_process"; +import { dirname } from "node:path"; +import { fileURLToPath } from "node:url"; + +import { loadReleaseWorkspaces } from "./validate-npm-dist-tag.mjs"; + +const repoRoot = dirname(dirname(fileURLToPath(import.meta.url))); + +export function detectReleaseChannel(before, after) { + const beforeVersions = new Map( + before.map(({ name, version }) => [name, version]) + ); + return after.some( + ({ name, version }) => beforeVersions.get(name) !== version + ) + ? "latest" + : "next"; +} + +function readVersion(ref, manifestPath) { + try { + const manifest = execFileSync( + "git", + ["show", `${ref}:${manifestPath}`], + { cwd: repoRoot, encoding: "utf8" } + ); + return JSON.parse(manifest).version; + } catch { + return undefined; + } +} + +async function main() { + const [baseRef, headRef] = process.argv.slice(2); + if (!baseRef || !headRef) { + throw new Error( + "Usage: node scripts/detect-release-channel.mjs " + ); + } + const workspaces = await loadReleaseWorkspaces(repoRoot); + const mergeBase = execFileSync("git", ["merge-base", baseRef, headRef], { + cwd: repoRoot, + encoding: "utf8", + }).trim(); + const before = workspaces.map(({ name, manifestPath }) => ({ + name, + version: readVersion(mergeBase, manifestPath), + })); + const after = workspaces.map(({ name, manifestPath }) => ({ + name, + version: readVersion(headRef, manifestPath), + })); + const channel = detectReleaseChannel(before, after); + const changed = after + .filter( + ({ name, version }) => + before.find((workspace) => workspace.name === name)?.version !== version + ) + .map(({ name }) => name); + console.error( + changed.length > 0 + ? `Version changes found in ${changed.length} publishable workspace(s); using latest` + : "No publishable workspace version changes found; using next preview" + ); + console.log(channel); +} + +if (process.argv[1] === fileURLToPath(import.meta.url)) { + main().catch((error) => { + console.error(error.message); + process.exitCode = 1; + }); +} diff --git a/scripts/detect-release-channel.test.mjs b/scripts/detect-release-channel.test.mjs new file mode 100644 index 000000000..0d9d1bb60 --- /dev/null +++ b/scripts/detect-release-channel.test.mjs @@ -0,0 +1,33 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { detectReleaseChannel } from "./detect-release-channel.mjs"; + +const release = [ + { name: "@onekeyfe/module-a", version: "3.0.152" }, + { name: "@onekeyfe/module-b", version: "3.0.152" }, +]; + +test("uses next when the change does not update package versions", () => { + assert.equal(detectReleaseChannel(release, release), "next"); +}); + +test("uses latest when a package version changes", () => { + assert.equal( + detectReleaseChannel(release, [ + { name: "@onekeyfe/module-a", version: "3.0.153" }, + release[1], + ]), + "latest" + ); +}); + +test("uses latest when a publishable package is added", () => { + assert.equal( + detectReleaseChannel(release, [ + ...release, + { name: "@onekeyfe/module-c", version: "3.0.153" }, + ]), + "latest" + ); +}); diff --git a/scripts/prepare-preview.mjs b/scripts/prepare-preview.mjs new file mode 100644 index 000000000..d31e2a022 --- /dev/null +++ b/scripts/prepare-preview.mjs @@ -0,0 +1,77 @@ +import { readFile, writeFile } from "node:fs/promises"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; + +import { loadReleaseWorkspaces } from "./validate-npm-dist-tag.mjs"; + +const repoRoot = dirname(dirname(fileURLToPath(import.meta.url))); +const dependencySections = [ + "dependencies", + "devDependencies", + "optionalDependencies", + "peerDependencies", +]; + +export function makePreviewVersion(stableVersion, previewNumber) { + const match = /^(\d+)\.(\d+)\.(\d+)$/.exec(stableVersion); + if (!match) { + throw new Error(`Preview base must be a stable version, got ${stableVersion}`); + } + if (!/^\d+$/.test(String(previewNumber))) { + throw new Error(`Preview number must be numeric, got ${previewNumber}`); + } + return `${match[1]}.${match[2]}.${Number(match[3]) + 1}-alpha.${previewNumber}`; +} + +export function updatePreviewManifest( + manifest, + { currentVersion, previewVersion, workspaceNames } +) { + const updated = { ...manifest, version: previewVersion }; + for (const section of dependencySections) { + if (!manifest[section]) { + continue; + } + updated[section] = { ...manifest[section] }; + for (const [name, version] of Object.entries(updated[section])) { + if (workspaceNames.has(name) && version === currentVersion) { + updated[section][name] = previewVersion; + } + } + } + return updated; +} + +async function main() { + const [previewNumber] = process.argv.slice(2); + const workspaces = await loadReleaseWorkspaces(repoRoot); + const versions = new Set(workspaces.map(({ version }) => version)); + if (workspaces.length === 0 || versions.size !== 1) { + throw new Error("Publishable workspace versions must match before preview"); + } + const [currentVersion] = versions; + const previewVersion = makePreviewVersion(currentVersion, previewNumber); + const workspaceNames = new Set(workspaces.map(({ name }) => name)); + + for (const { manifestPath } of workspaces) { + const path = join(repoRoot, manifestPath); + const manifest = JSON.parse(await readFile(path, "utf8")); + const updated = updatePreviewManifest(manifest, { + currentVersion, + previewVersion, + workspaceNames, + }); + await writeFile(path, `${JSON.stringify(updated, null, 2)}\n`); + } + console.error( + `Prepared ${workspaces.length} preview workspaces at ${previewVersion}` + ); + console.log(previewVersion); +} + +if (process.argv[1] === fileURLToPath(import.meta.url)) { + main().catch((error) => { + console.error(error.message); + process.exitCode = 1; + }); +} diff --git a/scripts/prepare-preview.test.mjs b/scripts/prepare-preview.test.mjs new file mode 100644 index 000000000..259a7f9ba --- /dev/null +++ b/scripts/prepare-preview.test.mjs @@ -0,0 +1,47 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + makePreviewVersion, + updatePreviewManifest, +} from "./prepare-preview.mjs"; + +test("increments the patch and appends the numeric alpha identifier", () => { + assert.equal(makePreviewVersion("3.0.152", "27"), "3.0.153-alpha.27"); +}); + +test("requires a stable preview base", () => { + assert.throws( + () => makePreviewVersion("3.0.153-alpha.1", "2"), + /must be a stable version/ + ); +}); + +test("updates package and exact internal dependency versions", () => { + const manifest = { + name: "@onekeyfe/module-a", + version: "3.0.152", + dependencies: { + "@onekeyfe/module-b": "3.0.152", + external: "^1.0.0", + }, + }; + assert.deepEqual( + updatePreviewManifest(manifest, { + currentVersion: "3.0.152", + previewVersion: "3.0.153-alpha.27", + workspaceNames: new Set([ + "@onekeyfe/module-a", + "@onekeyfe/module-b", + ]), + }), + { + ...manifest, + version: "3.0.153-alpha.27", + dependencies: { + "@onekeyfe/module-b": "3.0.153-alpha.27", + external: "^1.0.0", + }, + } + ); +}); diff --git a/scripts/publish-missing.mjs b/scripts/publish-missing.mjs new file mode 100644 index 000000000..a70cdfdfb --- /dev/null +++ b/scripts/publish-missing.mjs @@ -0,0 +1,140 @@ +import { spawnSync } from "node:child_process"; +import { dirname } from "node:path"; +import { fileURLToPath } from "node:url"; + +import { + loadReleaseWorkspaces, + validateNpmDistTag, +} from "./validate-npm-dist-tag.mjs"; +import { packumentUrl } from "./verify-published.mjs"; + +const repoRoot = dirname(dirname(fileURLToPath(import.meta.url))); +const nativeListName = "@onekeyfe/react-native-native-list"; + +function compareStableVersions(left, right) { + if (!/^\d+\.\d+\.\d+$/.test(left) || !/^\d+\.\d+\.\d+$/.test(right)) { + return null; + } + const a = left.split(".").map(Number); + const b = right.split(".").map(Number); + for (let index = 0; index < 3; index += 1) { + if (a[index] !== b[index]) { + return a[index] - b[index]; + } + } + return 0; +} + +export function selectUnpublished(workspaces, packuments, distTag) { + const missing = []; + for (const workspace of workspaces) { + const document = packuments.get(workspace.name); + const tagged = document?.["dist-tags"]?.[distTag]; + if (tagged && compareStableVersions(tagged, workspace.version) > 0) { + throw new Error( + `Refusing to move ${workspace.name} ${distTag} backwards from ${tagged} to ${workspace.version}` + ); + } + if (document?.versions?.[workspace.version]) { + continue; + } else { + missing.push(workspace); + } + } + return missing; +} + +export function selectRetags(workspaces, packuments, distTag) { + return workspaces.filter((workspace) => { + const document = packuments.get(workspace.name); + return ( + document?.versions?.[workspace.version] && + document?.["dist-tags"]?.[distTag] !== workspace.version + ); + }); +} + +async function readPackument(name) { + const response = await fetch(packumentUrl(name), { + headers: { accept: "application/json" }, + }); + if (response.status === 404) { + return null; + } + if (!response.ok) { + throw new Error(`Registry returned HTTP ${response.status} for ${name}`); + } + return response.json(); +} + +function runYarn(args) { + const result = spawnSync("yarn", args, { cwd: repoRoot, stdio: "inherit" }); + if (result.error) { + throw result.error; + } + if (result.status !== 0) { + throw new Error(`yarn ${args.join(" ")} failed with exit ${result.status}`); + } +} + +function runNpm(args) { + const result = spawnSync("npm", args, { cwd: repoRoot, stdio: "inherit" }); + if (result.error) { + throw result.error; + } + if (result.status !== 0) { + throw new Error(`npm ${args.join(" ")} failed with exit ${result.status}`); + } +} + +async function main() { + const [distTag] = process.argv.slice(2); + const workspaces = await loadReleaseWorkspaces(repoRoot); + validateNpmDistTag(distTag, workspaces); + if ( + workspaces.length === 0 || + new Set(workspaces.map(({ version }) => version)).size !== 1 + ) { + throw new Error("Publishable workspace versions must match before release"); + } + const packuments = new Map(); + for (const { name } of workspaces) { + packuments.set(name, await readPackument(name)); + } + const missing = selectUnpublished(workspaces, packuments, distTag); + const retags = selectRetags(workspaces, packuments, distTag); + console.log( + `${missing.length} package(s) need publishing; ${retags.length} existing version(s) need the ${distTag} tag` + ); + const first = missing.filter(({ name }) => name !== nativeListName); + if (first.length > 0) { + runYarn([ + "workspaces", + "foreach", + "--all", + "--topological", + "--parallel", + "--jobs", + "4", + "--interlaced", + ...first.flatMap(({ name }) => ["--include", name]), + "run", + "release", + "--tag", + distTag, + ]); + } + if (missing.some(({ name }) => name === nativeListName)) { + runYarn(["workspace", nativeListName, "release", "--tag", distTag]); + } + for (const { name, version } of retags) { + runNpm(["dist-tag", "add", `${name}@${version}`, distTag]); + } +} + +if (process.argv[1] === fileURLToPath(import.meta.url)) { + main().catch((error) => { + console.error(error.message); + process.exitCode = 1; + }); +} diff --git a/scripts/publish-missing.test.mjs b/scripts/publish-missing.test.mjs new file mode 100644 index 000000000..d0984d367 --- /dev/null +++ b/scripts/publish-missing.test.mjs @@ -0,0 +1,50 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { selectRetags, selectUnpublished } from "./publish-missing.mjs"; + +const workspaces = [ + { name: "@onekeyfe/module-a", version: "3.0.152" }, + { name: "@onekeyfe/module-b", version: "3.0.152" }, +]; + +test("skips already published packages on a retry", () => { + const packuments = new Map([ + [ + "@onekeyfe/module-a", + { versions: { "3.0.152": {} }, "dist-tags": { latest: "3.0.152" } }, + ], + ["@onekeyfe/module-b", { versions: { "3.0.151": {} } }], + ]); + assert.deepEqual(selectUnpublished(workspaces, packuments, "latest"), [ + workspaces[1], + ]); +}); + +test("retags an existing version instead of trying to republish it", () => { + const packuments = new Map([ + [ + "@onekeyfe/module-a", + { versions: { "3.0.152": {} }, "dist-tags": { latest: "3.0.151" } }, + ], + ]); + assert.deepEqual(selectUnpublished(workspaces, packuments, "latest"), [ + workspaces[1], + ]); + assert.deepEqual(selectRetags(workspaces, packuments, "latest"), [ + workspaces[0], + ]); +}); + +test("never moves latest backwards when a newer release won the race", () => { + const packuments = new Map([ + [ + "@onekeyfe/module-a", + { "dist-tags": { latest: "3.0.153" }, versions: {} }, + ], + ]); + assert.throws( + () => selectUnpublished(workspaces, packuments, "latest"), + /Refusing to move .* latest backwards/ + ); +}); diff --git a/scripts/sync-app-monorepo.mjs b/scripts/sync-app-monorepo.mjs new file mode 100644 index 000000000..e72cd783f --- /dev/null +++ b/scripts/sync-app-monorepo.mjs @@ -0,0 +1,102 @@ +import { readFile, writeFile } from "node:fs/promises"; +import { dirname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +import { loadReleaseWorkspaces } from "./validate-npm-dist-tag.mjs"; + +const repoRoot = dirname(dirname(fileURLToPath(import.meta.url))); + +function compareVersions(left, right) { + const parse = (version) => { + if (!/^\d+\.\d+\.\d+$/.test(version)) { + throw new Error(`Expected an exact stable version, got ${version}`); + } + return version.split(".").map(Number); + }; + const a = parse(left); + const b = parse(right); + for (let index = 0; index < 3; index += 1) { + if (a[index] !== b[index]) { + return a[index] - b[index]; + } + } + return 0; +} + +export function updateMobileManifest( + manifestText, + workspaces, + sections = ["dependencies"] +) { + const manifest = JSON.parse(manifestText); + const versions = new Set(workspaces.map(({ version }) => version)); + if (workspaces.length === 0 || versions.size !== 1) { + throw new Error("Publishable workspace versions must match"); + } + const byName = new Map( + workspaces.map(({ name, version }) => [name, version]) + ); + const updated = []; + for (const section of sections) { + for (const [name, current] of Object.entries(manifest[section] ?? {})) { + const alias = /^npm:((?:@[^/]+\/)?[^@]+)@(.+)$/.exec(current); + const workspaceName = alias ? alias[1] : name; + const currentVersion = alias ? alias[2] : current; + const target = byName.get(workspaceName); + if (!target || currentVersion === target) { + continue; + } + if (compareVersions(currentVersion, target) > 0) { + throw new Error( + `Refusing to downgrade ${name} from ${currentVersion} to ${target}` + ); + } + manifest[section][name] = alias + ? `npm:${workspaceName}@${target}` + : target; + updated.push(name); + } + } + return { + text: + updated.length > 0 + ? `${JSON.stringify(manifest, null, 2)}\n` + : manifestText, + updated, + }; +} + +async function main() { + const [appMonorepoArg] = process.argv.slice(2); + if (!appMonorepoArg) { + throw new Error( + "Usage: node scripts/sync-app-monorepo.mjs " + ); + } + const workspaces = await loadReleaseWorkspaces(repoRoot); + const manifests = [ + ["apps/mobile/package.json", ["dependencies"]], + ["packages/components/package.json", ["dependencies"]], + ["package.json", ["dependencies", "resolutions"]], + ]; + for (const [relativePath, sections] of manifests) { + const manifestPath = join(resolve(appMonorepoArg), relativePath); + const original = await readFile(manifestPath, "utf8"); + const { text, updated } = updateMobileManifest( + original, + workspaces, + sections + ); + if (updated.length > 0) { + await writeFile(manifestPath, text); + } + console.log(`Updated ${updated.length} dependencies in ${relativePath}`); + } +} + +if (process.argv[1] === fileURLToPath(import.meta.url)) { + main().catch((error) => { + console.error(error.message); + process.exitCode = 1; + }); +} diff --git a/scripts/sync-app-monorepo.test.mjs b/scripts/sync-app-monorepo.test.mjs new file mode 100644 index 000000000..876810733 --- /dev/null +++ b/scripts/sync-app-monorepo.test.mjs @@ -0,0 +1,111 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { updateMobileManifest } from "./sync-app-monorepo.mjs"; + +const workspaces = [ + { name: "@onekeyfe/react-native-image", version: "3.0.152" }, + { name: "@onekeyfe/react-native-native-list", version: "3.0.152" }, + { name: "@onekeyfe/react-native-get-random-values", version: "3.0.152" }, +]; + +test("updates only packages published by app-modules", () => { + const input = `${JSON.stringify( + { + dependencies: { + "@onekeyfe/react-native-image": "3.0.151", + "@onekeyfe/react-native-native-list": "3.0.151", + "@onekeyfe/react-native-ble-utils": "0.1.6", + }, + }, + null, + 2 + )}\n`; + const result = updateMobileManifest(input, workspaces); + assert.deepEqual(result.updated, [ + "@onekeyfe/react-native-image", + "@onekeyfe/react-native-native-list", + ]); + assert.equal( + JSON.parse(result.text).dependencies["@onekeyfe/react-native-ble-utils"], + "0.1.6" + ); + assert.equal( + JSON.parse(result.text).dependencies["@onekeyfe/react-native-image"], + "3.0.152" + ); +}); + +test("does nothing when app-monorepo already uses the release", () => { + const input = '{"dependencies":{"@onekeyfe/react-native-image":"3.0.152"}}'; + assert.deepEqual(updateMobileManifest(input, workspaces), { + text: input, + updated: [], + }); +}); + +test("updates npm aliases without changing their dependency keys", () => { + const input = + '{"dependencies":{"react-native-get-random-values":"npm:@onekeyfe/react-native-get-random-values@3.0.151","unrelated":"npm:@onekeyfe/unrelated@1.0.0"}}'; + const result = updateMobileManifest(input, workspaces); + assert.deepEqual(result.updated, ["react-native-get-random-values"]); + assert.deepEqual(JSON.parse(result.text).dependencies, { + "react-native-get-random-values": + "npm:@onekeyfe/react-native-get-random-values@3.0.152", + unrelated: "npm:@onekeyfe/unrelated@1.0.0", + }); +}); + +test("updates root dependency and resolution pins", () => { + const input = JSON.stringify({ + dependencies: { + "react-native-get-random-values": + "npm:@onekeyfe/react-native-get-random-values@3.0.151", + }, + resolutions: { + "react-native-get-random-values": + "npm:@onekeyfe/react-native-get-random-values@3.0.151", + }, + }); + const result = updateMobileManifest(input, workspaces, [ + "dependencies", + "resolutions", + ]); + const manifest = JSON.parse(result.text); + assert.equal( + manifest.dependencies["react-native-get-random-values"], + "npm:@onekeyfe/react-native-get-random-values@3.0.152" + ); + assert.equal( + manifest.resolutions["react-native-get-random-values"], + "npm:@onekeyfe/react-native-get-random-values@3.0.152" + ); +}); + +test("refuses to downgrade a newer app-monorepo dependency", () => { + const input = '{"dependencies":{"@onekeyfe/react-native-image":"3.0.153"}}'; + assert.throws( + () => updateMobileManifest(input, workspaces), + /Refusing to downgrade/ + ); +}); + +test("refuses to downgrade an npm alias", () => { + const input = + '{"dependencies":{"react-native-get-random-values":"npm:@onekeyfe/react-native-get-random-values@3.0.153"}}'; + assert.throws( + () => updateMobileManifest(input, workspaces), + /Refusing to downgrade/ + ); +}); + +test("requires a synchronized app-modules release", () => { + assert.throws( + () => + updateMobileManifest("{}", [ + { ...workspaces[0], version: "3.0.151" }, + workspaces[1], + ]), + /versions must match/ + ); +}); diff --git a/scripts/validate-npm-dist-tag.mjs b/scripts/validate-npm-dist-tag.mjs index cdc01ee01..074a2313a 100644 --- a/scripts/validate-npm-dist-tag.mjs +++ b/scripts/validate-npm-dist-tag.mjs @@ -59,9 +59,15 @@ export async function loadReleaseWorkspaces(repoRoot) { workspacePackage.private !== true && typeof workspacePackage.scripts?.release === "string" ) { + const manifestPath = join( + workspacePattern.slice(0, -2), + entry.name, + "package.json" + ); releaseWorkspaces.push({ name: workspacePackage.name, version: workspacePackage.version, + manifestPath, }); } } diff --git a/scripts/verify-published.mjs b/scripts/verify-published.mjs index 01a79e442..4a39e667f 100644 --- a/scripts/verify-published.mjs +++ b/scripts/verify-published.mjs @@ -14,8 +14,8 @@ const REGISTRY = "https://registry.npmjs.org"; // ("409 Cannot publish over previously staged version"), so the number was // burned and the whole set had to move to 3.0.138. This check exists so that a // release like that fails loudly in the run that produced it. -const DEFAULT_TIMEOUT_MS = 10 * 60 * 1000; -const POLL_INTERVAL_MS = 15 * 1000; +const DEFAULT_MAX_ATTEMPTS = 10; +const POLL_INTERVAL_MS = 45 * 1000; /** * The workspaces a run was supposed to publish. `onlyWorkspace` mirrors the @@ -88,13 +88,20 @@ const sleep = (ms) => export async function verifyPublished( workspaces, distTag, - { timeoutMs = DEFAULT_TIMEOUT_MS, log = console.log } = {} + { + maxAttempts = DEFAULT_MAX_ATTEMPTS, + pollIntervalMs = POLL_INTERVAL_MS, + log = console.log, + } = {} ) { - const deadline = Date.now() + timeoutMs; let pending = workspaces; - let lastReasons = new Map(); + const lastReasons = new Map(); - while (pending.length > 0) { + for ( + let attempt = 1; + attempt <= maxAttempts && pending.length > 0; + attempt += 1 + ) { const stillPending = []; for (const workspace of pending) { let result; @@ -115,18 +122,15 @@ export async function verifyPublished( } } pending = stillPending; - if (pending.length === 0) { - break; - } - if (Date.now() >= deadline) { + if (pending.length === 0 || attempt === maxAttempts) { break; } log( ` waiting ${pending.length} package(s) not visible yet; re-checking in ${ - POLL_INTERVAL_MS / 1000 + pollIntervalMs / 1000 }s` ); - await sleep(POLL_INTERVAL_MS); + await sleep(pollIntervalMs); } return pending.map((workspace) => ({ @@ -158,10 +162,9 @@ async function main() { missing .map(({ name, version, reason }) => ` - ${name}@${version}: ${reason}`) .join("\n") + - "\n\nnpm accepted these publishes but the registry never served them. " + - "The version numbers are likely burned (republishing returns 409 " + - "'Cannot publish over previously staged version'), so the fix is " + - "usually to bump and release again." + "\n\nThe versions were not visible after 10 checks. They may still be " + + "propagating; retry verification before considering a new release. " + + "If npm rejects republishing with 409, the staged version may be burned." ); process.exitCode = 1; return; diff --git a/scripts/verify-published.test.mjs b/scripts/verify-published.test.mjs index fe89cd4cb..1728f9599 100644 --- a/scripts/verify-published.test.mjs +++ b/scripts/verify-published.test.mjs @@ -76,7 +76,7 @@ test("reports every package that never became available", async () => { }); try { const missing = await verifyPublished(workspaces, "latest", { - timeoutMs: 0, + maxAttempts: 1, log: () => {}, }); assert.deepEqual( @@ -99,7 +99,7 @@ test("returns nothing missing once every package is visible", async () => { }); try { const missing = await verifyPublished(workspaces, "latest", { - timeoutMs: 0, + maxAttempts: 1, log: () => {}, }); assert.deepEqual(missing, []); @@ -107,3 +107,29 @@ test("returns nothing missing once every package is visible", async () => { globalThis.fetch = originalFetch; } }); + +test("checks a missing package at most ten times", async () => { + const originalFetch = globalThis.fetch; + let checks = 0; + let waits = 0; + globalThis.fetch = async () => { + checks += 1; + return { + ok: true, + json: async () => ({ versions: {}, "dist-tags": {} }), + }; + }; + try { + const missing = await verifyPublished([workspaces[0]], "latest", { + pollIntervalMs: 0, + log: (message) => { + if (message.includes("waiting")) waits += 1; + }, + }); + assert.equal(checks, 10); + assert.equal(waits, 9); + assert.deepEqual(missing.map(({ name }) => name), [workspaces[0].name]); + } finally { + globalThis.fetch = originalFetch; + } +});