From 06caf619453d690d0f4f79f44fa2cba0bddd580c Mon Sep 17 00:00:00 2001 From: huhuanming Date: Sun, 20 Sep 2026 22:10:28 +0800 Subject: [PATCH 1/7] feat: automate app-modules release and app-monorepo sync --- .github/workflows/package-publish.yml | 131 +++++++++++++++++++++++--- .github/workflows/prepare-release.yml | 90 ++++++++++++++++++ README.md | 14 ++- scripts/prepare-release.mjs | 98 +++++++++++++++++++ scripts/prepare-release.test.mjs | 59 ++++++++++++ scripts/publish-missing.mjs | 122 ++++++++++++++++++++++++ scripts/publish-missing.test.mjs | 48 ++++++++++ scripts/sync-app-monorepo.mjs | 83 ++++++++++++++++ scripts/sync-app-monorepo.test.mjs | 63 +++++++++++++ 9 files changed, 687 insertions(+), 21 deletions(-) create mode 100644 .github/workflows/prepare-release.yml create mode 100644 scripts/prepare-release.mjs create mode 100644 scripts/prepare-release.test.mjs create mode 100644 scripts/publish-missing.mjs create mode 100644 scripts/publish-missing.test.mjs create mode 100644 scripts/sync-app-monorepo.mjs create mode 100644 scripts/sync-app-monorepo.test.mjs diff --git a/.github/workflows/package-publish.yml b/.github/workflows/package-publish.yml index fc5f7905b..9f18802d4 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: @@ -20,39 +23,65 @@ on: default: "" type: string +permissions: + contents: read + +concurrency: + group: app-modules-package-publish + queue: max + jobs: package-publish: + if: >- + github.event_name == 'workflow_dispatch' || + (github.event.pull_request.merged == true && + github.event.pull_request.head.repo.full_name == github.repository && + startsWith(github.event.pull_request.head.ref, 'codex/release-app-modules-v')) runs-on: ubuntu-latest + env: + NPM_DIST_TAG: ${{ github.event_name == 'pull_request' && 'latest' || inputs.npm_dist_tag }} + 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 }} - uses: actions/setup-node@v6 with: node-version: "24.x" registry-url: "https://registry.npmjs.org" - name: Validate npm dist-tag - env: - NPM_DIST_TAG: ${{ inputs.npm_dist_tag }} run: node scripts/validate-npm-dist-tag.mjs "$NPM_DIST_TAG" - name: Install Package - run: corepack enable && yarn install + run: corepack enable && yarn install --immutable + - name: Match the merged release branch to the package version + if: github.event_name == 'pull_request' + env: + RELEASE_HEAD_REF: ${{ github.event.pull_request.head.ref }} + run: | + version=$(node -p "require('./native-views/react-native-native-list/package.json').version") + test "$RELEASE_HEAD_REF" = "codex/release-app-modules-v${version}" || { + echo 'Release branch and package version do not match' + exit 1 + } + - name: Publish missing packages after release PR merge + if: github.event_name == 'pull_request' + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + run: node scripts/publish-missing.mjs "$NPM_DIST_TAG" - name: Publish packages (4 concurrent) - if: inputs.only_workspace == '' + 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 version:publish --tag "$NPM_DIST_TAG" - name: Publish NativeList after shared build dependencies - if: inputs.only_workspace == '' + 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 != '' + 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 }} 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 @@ -60,7 +89,83 @@ jobs: # 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 }} run: node scripts/verify-published.mjs "$NPM_DIST_TAG" "$ONLY_WORKSPACE" + + sync-app-monorepo: + needs: package-publish + if: github.event_name == 'pull_request' + runs-on: ubuntu-latest + steps: + - name: Create app-monorepo-scoped token + id: app-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 + permission-packages: read + - uses: actions/checkout@v6 + with: + ref: ${{ github.event.pull_request.merge_commit_sha }} + path: app-modules + - uses: actions/checkout@v6 + with: + repository: OneKeyHQ/app-monorepo + ref: x + path: app-monorepo + token: ${{ steps.app-token.outputs.token }} + - uses: actions/setup-node@v6 + with: + node-version: '24.x' + registry-url: https://npm.pkg.github.com + scope: '@onekeyhq' + - 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; 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 + env: + NODE_AUTH_TOKEN: ${{ steps.app-token.outputs.token }} + run: | + corepack enable + yarn install --mode=skip-build + yarn install --immutable --mode=skip-build + yarn workspace @onekeyhq/mobile module-id:check + git diff --check + - name: Create or update app-monorepo PR + if: steps.dependencies.outputs.changed == 'true' + working-directory: app-monorepo + env: + GH_TOKEN: ${{ steps.app-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 yarn.lock + git diff --cached --check + git commit -m "chore: update app-modules to ${version}" + 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 --head "OneKeyHQ:${branch}" --json number --jq '.[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/.github/workflows/prepare-release.yml b/.github/workflows/prepare-release.yml new file mode 100644 index 000000000..f6126b205 --- /dev/null +++ b/.github/workflows/prepare-release.yml @@ -0,0 +1,90 @@ +name: prepare-release + +on: + pull_request: + types: [closed] + branches: [main] + workflow_dispatch: + +permissions: + contents: read + +# A newer merge must not replace a pending release preparation. +concurrency: + group: app-modules-prepare-release + queue: max + +jobs: + prepare-release: + if: >- + github.event_name == 'workflow_dispatch' || + (github.event.pull_request.merged == true && + !(github.event.pull_request.head.repo.full_name == github.repository && + startsWith(github.event.pull_request.head.ref, 'codex/release-app-modules-v'))) + runs-on: ubuntu-latest + steps: + - name: Create repository-scoped release token + id: app-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-modules + permission-contents: write + permission-pull-requests: write + - uses: actions/checkout@v6 + with: + ref: main + fetch-depth: 0 + token: ${{ steps.app-token.outputs.token }} + - name: Check for unreleased merges + id: changes + run: | + previous_release=$(git log -1 --format=%H --extended-regexp '--grep=^release: app-modules [0-9]+\.[0-9]+\.[0-9]+') + test -n "$previous_release" || { echo 'Previous release commit not found'; exit 1; } + if test "$(git rev-list --count "$previous_release"..HEAD)" -gt 0; then + echo 'needed=true' >> "$GITHUB_OUTPUT" + else + echo 'No changes since the previous release' + fi + - uses: actions/setup-node@v6 + if: steps.changes.outputs.needed == 'true' + with: + node-version: "24.x" + - name: Bump versions and prepare changelog + if: steps.changes.outputs.needed == 'true' + run: | + corepack enable + yarn install --immutable + node --test scripts/*.test.mjs + yarn version:patch + yarn install + node scripts/prepare-release.mjs + node scripts/validate-npm-dist-tag.mjs latest + git diff --check + - name: Create or update release PR + if: steps.changes.outputs.needed == 'true' + env: + GH_TOKEN: ${{ steps.app-token.outputs.token }} + run: | + version=$(node -p "require('./native-views/react-native-native-list/package.json').version") + branch="codex/release-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 CHANGELOG.md yarn.lock native-modules native-views + git diff --cached --check + git commit -m "release: app-modules ${version}" + 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="Automated version and changelog update for app-modules ${version}. The publish workflow will run only after this PR merges into main." + pr_number=$(gh pr list --repo "$GITHUB_REPOSITORY" --state open --head "${GITHUB_REPOSITORY_OWNER}:${branch}" --json number --jq '.[0].number') + if test -n "$pr_number"; then + gh pr edit "$pr_number" --repo "$GITHUB_REPOSITORY" --title "release: app-modules ${version}" --body "$body" + else + gh pr create --repo "$GITHUB_REPOSITORY" --base main --head "$branch" --title "release: app-modules ${version}" --body "$body" + fi diff --git a/README.md b/README.md index d70bb6832..5459703c5 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: +Merging a PR into `main` creates or updates a release PR with synchronized package versions, `yarn.lock`, and a `CHANGELOG.md` entry. Write detailed release notes under `Unreleased` in the change PR when needed; the release PR also lists every merged commit since the previous release. Once the release PR merges, `package-publish.yml` publishes missing packages with the npm `latest` tag, verifies all published versions on npm, and opens an app-monorepo PR against `x` for the mobile dependency and lockfile updates. The existing manual publish action remains available for `next`, `latest`, and single-workspace recovery. -```shell -yarn version:bump -yarn version:apply -``` -Commit version changes and push to GitHub. +Before enabling the workflows, install a GitHub App on both `app-modules` and `app-monorepo` with repository Contents (write), Pull requests (write), and Packages (read) 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 creates release and dependency PRs; its token allows the PR checks to run without the approval gate applied to PRs created with `GITHUB_TOKEN`. + +The current `main` ruleset requires one approval and has no bypass actor, so the release PR must be approved before automatic publication can continue. Granting a dedicated App a narrowly scoped ruleset exception is an administrator decision, not part of this workflow. 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/prepare-release.mjs b/scripts/prepare-release.mjs new file mode 100644 index 000000000..6f409681b --- /dev/null +++ b/scripts/prepare-release.mjs @@ -0,0 +1,98 @@ +import { execFileSync } from "node:child_process"; +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 changelogPath = join(repoRoot, "CHANGELOG.md"); + +export function renderReleaseChangelog( + changelog, + { version, date, commitTitles, workspaceCount } +) { + const marker = "## [Unreleased]\n"; + const markerStart = changelog.indexOf(marker); + if (markerStart < 0 || changelog.indexOf(marker, markerStart + 1) >= 0) { + throw new Error("CHANGELOG.md must contain exactly one Unreleased section"); + } + if (changelog.includes(`## [${version}]`)) { + throw new Error(`CHANGELOG.md already contains ${version}`); + } + if (commitTitles.length === 0) { + throw new Error("No merged commits since the previous release"); + } + + const bodyStart = markerStart + marker.length; + const remaining = changelog.slice(bodyStart); + const nextHeading = remaining.search(/^## \[/m); + if (nextHeading < 0) { + throw new Error("CHANGELOG.md has no previous release section"); + } + const unreleased = remaining.slice(0, nextHeading).trim(); + const previousReleases = remaining.slice(nextHeading); + const mergedChanges = commitTitles.map((title) => `- ${title}`).join("\n"); + const releaseBody = [ + unreleased, + `### Merged changes\n${mergedChanges}`, + `### Version\n- Bump all ${workspaceCount} publishable packages to ${version}.`, + ] + .filter(Boolean) + .join("\n\n"); + + return ( + changelog.slice(0, bodyStart) + + `\n## [${version}] - ${date}\n\n${releaseBody}\n\n` + + previousReleases + ); +} + +function git(...args) { + return execFileSync("git", args, { cwd: repoRoot, encoding: "utf8" }).trim(); +} + +async function main() { + const previousRelease = git( + "log", + "-1", + "--format=%H", + "--extended-regexp", + "--grep=^release: app-modules [0-9]+\\.[0-9]+\\.[0-9]+" + ); + if (!previousRelease) { + throw new Error("Cannot find the previous app-modules release commit"); + } + const commitTitles = git( + "log", + "--reverse", + "--format=%s", + `${previousRelease}..HEAD` + ) + .split("\n") + .filter(Boolean); + const workspaces = await loadReleaseWorkspaces(repoRoot); + const versions = new Set(workspaces.map(({ version }) => version)); + if (versions.size !== 1 || workspaces.length === 0) { + throw new Error("Publishable workspace versions must match before release"); + } + const [version] = versions; + const changelog = await readFile(changelogPath, "utf8"); + const updated = renderReleaseChangelog(changelog, { + version, + date: new Date().toISOString().slice(0, 10), + commitTitles, + workspaceCount: workspaces.length, + }); + await writeFile(changelogPath, updated); + console.log( + `Prepared CHANGELOG.md for ${version} (${commitTitles.length} commits)` + ); +} + +if (process.argv[1] === fileURLToPath(import.meta.url)) { + main().catch((error) => { + console.error(error.message); + process.exitCode = 1; + }); +} diff --git a/scripts/prepare-release.test.mjs b/scripts/prepare-release.test.mjs new file mode 100644 index 000000000..44643a871 --- /dev/null +++ b/scripts/prepare-release.test.mjs @@ -0,0 +1,59 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { renderReleaseChangelog } from "./prepare-release.mjs"; + +const changelog = + "# Changelog\n\n## [Unreleased]\n\n### Bug Fixes\n- Detailed fix.\n\n## [3.0.151] - 2026-09-20\n\nOlder notes.\n"; + +test("preserves detailed unreleased notes and lists every merged change", () => { + const result = renderReleaseChangelog(changelog, { + version: "3.0.152", + date: "2026-09-21", + commitTitles: ["fix: first (#122)", "feat: second (#123)"], + workspaceCount: 41, + }); + assert.match(result, /## \[Unreleased\]\n\n## \[3\.0\.152\] - 2026-09-21/); + assert.match(result, /### Bug Fixes\n- Detailed fix\./); + assert.match( + result, + /### Merged changes\n- fix: first \(#122\)\n- feat: second \(#123\)/ + ); + assert.match(result, /Bump all 41 publishable packages to 3\.0\.152/); + assert.match(result, /## \[3\.0\.151\] - 2026-09-20\n\nOlder notes\./); +}); + +test("creates a release without handwritten unreleased notes", () => { + const result = renderReleaseChangelog( + "# Changelog\n\n## [Unreleased]\n\n## [3.0.151] - 2026-09-20\n", + { + version: "3.0.152", + date: "2026-09-21", + commitTitles: ["fix: issue (#122)"], + workspaceCount: 41, + } + ); + assert.match(result, /## \[3\.0\.152\] - 2026-09-21\n\n### Merged changes/); +}); + +test("refuses duplicate versions and empty release ranges", () => { + const options = { + version: "3.0.151", + date: "2026-09-21", + commitTitles: ["fix: issue (#122)"], + workspaceCount: 41, + }; + assert.throws( + () => renderReleaseChangelog(changelog, options), + /already contains/ + ); + assert.throws( + () => + renderReleaseChangelog(changelog, { + ...options, + version: "3.0.152", + commitTitles: [], + }), + /No merged commits/ + ); +}); diff --git a/scripts/publish-missing.mjs b/scripts/publish-missing.mjs new file mode 100644 index 000000000..e6568ffb0 --- /dev/null +++ b/scripts/publish-missing.mjs @@ -0,0 +1,122 @@ +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]) { + if (tagged !== workspace.version) { + throw new Error( + `${workspace.name}@${ + workspace.version + } already exists but ${distTag} points at ${tagged ?? "nothing"}` + ); + } + } else { + missing.push(workspace); + } + } + return missing; +} + +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}`); + } +} + +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); + console.log( + `${missing.length} of ${workspaces.length} packages still need publishing` + ); + 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]); + } +} + +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..9ee164022 --- /dev/null +++ b/scripts/publish-missing.test.mjs @@ -0,0 +1,48 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { 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("refuses an existing version with the wrong dist-tag", () => { + const packuments = new Map([ + [ + "@onekeyfe/module-a", + { versions: { "3.0.152": {} }, "dist-tags": { latest: "3.0.151" } }, + ], + ]); + assert.throws( + () => selectUnpublished(workspaces, packuments, "latest"), + /already exists but latest points at 3\.0\.151/ + ); +}); + +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..ba7f42928 --- /dev/null +++ b/scripts/sync-app-monorepo.mjs @@ -0,0 +1,83 @@ +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) { + 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 [name, current] of Object.entries(manifest.dependencies ?? {})) { + const target = byName.get(name); + if (!target || current === target) { + continue; + } + if (compareVersions(current, target) > 0) { + throw new Error( + `Refusing to downgrade ${name} from ${current} to ${target}` + ); + } + manifest.dependencies[name] = 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 manifestPath = join( + resolve(appMonorepoArg), + "apps/mobile/package.json" + ); + const workspaces = await loadReleaseWorkspaces(repoRoot); + const original = await readFile(manifestPath, "utf8"); + const { text, updated } = updateMobileManifest(original, workspaces); + if (updated.length > 0) { + await writeFile(manifestPath, text); + } + console.log(`Updated ${updated.length} app-monorepo mobile dependencies`); +} + +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..ffde56525 --- /dev/null +++ b/scripts/sync-app-monorepo.test.mjs @@ -0,0 +1,63 @@ +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" }, +]; + +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("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("requires a synchronized app-modules release", () => { + assert.throws( + () => + updateMobileManifest("{}", [ + { ...workspaces[0], version: "3.0.151" }, + workspaces[1], + ]), + /versions must match/ + ); +}); From 0991722b2efe06f95805238aceb47b6cfc1c116b Mon Sep 17 00:00:00 2001 From: huhuanming Date: Sun, 20 Sep 2026 22:12:52 +0800 Subject: [PATCH 2/7] test: remove prepare-release tests --- scripts/prepare-release.test.mjs | 59 -------------------------------- 1 file changed, 59 deletions(-) delete mode 100644 scripts/prepare-release.test.mjs diff --git a/scripts/prepare-release.test.mjs b/scripts/prepare-release.test.mjs deleted file mode 100644 index 44643a871..000000000 --- a/scripts/prepare-release.test.mjs +++ /dev/null @@ -1,59 +0,0 @@ -import assert from "node:assert/strict"; -import test from "node:test"; - -import { renderReleaseChangelog } from "./prepare-release.mjs"; - -const changelog = - "# Changelog\n\n## [Unreleased]\n\n### Bug Fixes\n- Detailed fix.\n\n## [3.0.151] - 2026-09-20\n\nOlder notes.\n"; - -test("preserves detailed unreleased notes and lists every merged change", () => { - const result = renderReleaseChangelog(changelog, { - version: "3.0.152", - date: "2026-09-21", - commitTitles: ["fix: first (#122)", "feat: second (#123)"], - workspaceCount: 41, - }); - assert.match(result, /## \[Unreleased\]\n\n## \[3\.0\.152\] - 2026-09-21/); - assert.match(result, /### Bug Fixes\n- Detailed fix\./); - assert.match( - result, - /### Merged changes\n- fix: first \(#122\)\n- feat: second \(#123\)/ - ); - assert.match(result, /Bump all 41 publishable packages to 3\.0\.152/); - assert.match(result, /## \[3\.0\.151\] - 2026-09-20\n\nOlder notes\./); -}); - -test("creates a release without handwritten unreleased notes", () => { - const result = renderReleaseChangelog( - "# Changelog\n\n## [Unreleased]\n\n## [3.0.151] - 2026-09-20\n", - { - version: "3.0.152", - date: "2026-09-21", - commitTitles: ["fix: issue (#122)"], - workspaceCount: 41, - } - ); - assert.match(result, /## \[3\.0\.152\] - 2026-09-21\n\n### Merged changes/); -}); - -test("refuses duplicate versions and empty release ranges", () => { - const options = { - version: "3.0.151", - date: "2026-09-21", - commitTitles: ["fix: issue (#122)"], - workspaceCount: 41, - }; - assert.throws( - () => renderReleaseChangelog(changelog, options), - /already contains/ - ); - assert.throws( - () => - renderReleaseChangelog(changelog, { - ...options, - version: "3.0.152", - commitTitles: [], - }), - /No merged commits/ - ); -}); From 9207fca0e39c935f1b27f286fe518660ed30c271 Mon Sep 17 00:00:00 2001 From: huhuanming Date: Sun, 20 Sep 2026 22:46:19 +0800 Subject: [PATCH 3/7] fix: harden release automation and sync all module pins --- .github/workflows/package-publish.yml | 65 ++++++++++++++++++++++----- .github/workflows/prepare-release.yml | 39 ++++++++++------ README.md | 2 +- scripts/sync-app-monorepo.mjs | 61 ++++++++++++++++--------- scripts/sync-app-monorepo.test.mjs | 48 ++++++++++++++++++++ 5 files changed, 169 insertions(+), 46 deletions(-) diff --git a/.github/workflows/package-publish.yml b/.github/workflows/package-publish.yml index 9f18802d4..e2da40f7a 100644 --- a/.github/workflows/package-publish.yml +++ b/.github/workflows/package-publish.yml @@ -31,12 +31,42 @@ concurrency: queue: max jobs: + verify-release-pr: + if: >- + github.event_name == 'pull_request' && + github.event.pull_request.merged == true && + github.event.pull_request.head.repo.full_name == github.repository && + startsWith(github.event.pull_request.head.ref, 'codex/release-app-modules-v') + runs-on: ubuntu-latest + steps: + - name: Get the release App identity + id: app-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-modules + permission-contents: read + - name: Verify the release PR author + env: + EXPECTED_BOT: ${{ steps.app-token.outputs.app-slug }}[bot] + PR_AUTHOR: ${{ github.event.pull_request.user.login }} + run: | + test "$PR_AUTHOR" = "$EXPECTED_BOT" || { + echo "Release PR was not created by $EXPECTED_BOT" + exit 1 + } + package-publish: + needs: verify-release-pr if: >- - github.event_name == 'workflow_dispatch' || + always() && + (github.event_name == 'workflow_dispatch' || (github.event.pull_request.merged == true && github.event.pull_request.head.repo.full_name == github.repository && - startsWith(github.event.pull_request.head.ref, 'codex/release-app-modules-v')) + startsWith(github.event.pull_request.head.ref, 'codex/release-app-modules-v') && + needs.verify-release-pr.result == 'success')) runs-on: ubuntu-latest env: NPM_DIST_TAG: ${{ github.event_name == 'pull_request' && 'latest' || inputs.npm_dist_tag }} @@ -96,27 +126,28 @@ jobs: if: github.event_name == 'pull_request' runs-on: ubuntu-latest steps: - - name: Create app-monorepo-scoped token - id: app-token + - 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: write - permission-pull-requests: write + permission-contents: read permission-packages: 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.app-token.outputs.token }} + token: ${{ steps.read-token.outputs.token }} + persist-credentials: false - uses: actions/setup-node@v6 with: node-version: '24.x' @@ -127,7 +158,7 @@ jobs: working-directory: app-monorepo run: | node ../app-modules/scripts/sync-app-monorepo.mjs . - if git diff --quiet -- apps/mobile/package.json; then + 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" @@ -136,26 +167,38 @@ jobs: if: steps.dependencies.outputs.changed == 'true' working-directory: app-monorepo env: - NODE_AUTH_TOKEN: ${{ steps.app-token.outputs.token }} + NODE_AUTH_TOKEN: ${{ steps.read-token.outputs.token }} run: | corepack enable 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.app-token.outputs.token }} + 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 yarn.lock + 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}" diff --git a/.github/workflows/prepare-release.yml b/.github/workflows/prepare-release.yml index f6126b205..00460986b 100644 --- a/.github/workflows/prepare-release.yml +++ b/.github/workflows/prepare-release.yml @@ -15,29 +15,30 @@ 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 release. Run prepare-release manually when ready.' >> "$GITHUB_STEP_SUMMARY" + prepare-release: if: >- github.event_name == 'workflow_dispatch' || (github.event.pull_request.merged == true && - !(github.event.pull_request.head.repo.full_name == github.repository && - startsWith(github.event.pull_request.head.ref, 'codex/release-app-modules-v'))) + github.event.pull_request.head.repo.full_name == github.repository && + !startsWith(github.event.pull_request.head.ref, 'codex/release-app-modules-v')) runs-on: ubuntu-latest steps: - - name: Create repository-scoped release token - id: app-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-modules - permission-contents: write - permission-pull-requests: write - uses: actions/checkout@v6 with: ref: main fetch-depth: 0 - token: ${{ steps.app-token.outputs.token }} + persist-credentials: false - name: Check for unreleased merges id: changes run: | @@ -63,6 +64,17 @@ jobs: node scripts/prepare-release.mjs node scripts/validate-npm-dist-tag.mjs latest git diff --check + - name: Create repository-scoped release token + if: steps.changes.outputs.needed == 'true' + id: app-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-modules + permission-contents: write + permission-pull-requests: write - name: Create or update release PR if: steps.changes.outputs.needed == 'true' env: @@ -75,6 +87,7 @@ jobs: git add -u CHANGELOG.md yarn.lock native-modules native-views git diff --cached --check git commit -m "release: app-modules ${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}" diff --git a/README.md b/README.md index 5459703c5..7f5f51b38 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,7 @@ yarn package:setup new-lib ## Release automation -Merging a PR into `main` creates or updates a release PR with synchronized package versions, `yarn.lock`, and a `CHANGELOG.md` entry. Write detailed release notes under `Unreleased` in the change PR when needed; the release PR also lists every merged commit since the previous release. Once the release PR merges, `package-publish.yml` publishes missing packages with the npm `latest` tag, verifies all published versions on npm, and opens an app-monorepo PR against `x` for the mobile dependency and lockfile updates. The existing manual publish action remains available for `next`, `latest`, and single-workspace recovery. +Merging a same-repository PR into `main` creates or updates a release PR with synchronized package versions, `yarn.lock`, and a `CHANGELOG.md` entry. A merged fork PR is intentionally skipped with a workflow summary notice; run `prepare-release` manually when ready. Write detailed release notes under `Unreleased` in the change PR when needed; the release PR also lists every merged commit since the previous release. Once the release PR merges, `package-publish.yml` verifies the release PR was created by the configured App, publishes missing packages with the npm `latest` tag, verifies all published versions on npm, and opens an app-monorepo PR against `x` for the mobile, components, root dependency pins, and lockfile updates. The existing manual publish action remains available for `next`, `latest`, and single-workspace recovery. Before enabling the workflows, install a GitHub App on both `app-modules` and `app-monorepo` with repository Contents (write), Pull requests (write), and Packages (read) 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 creates release and dependency PRs; its token allows the PR checks to run without the approval gate applied to PRs created with `GITHUB_TOKEN`. diff --git a/scripts/sync-app-monorepo.mjs b/scripts/sync-app-monorepo.mjs index ba7f42928..e72cd783f 100644 --- a/scripts/sync-app-monorepo.mjs +++ b/scripts/sync-app-monorepo.mjs @@ -23,7 +23,11 @@ function compareVersions(left, right) { return 0; } -export function updateMobileManifest(manifestText, workspaces) { +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) { @@ -33,18 +37,25 @@ export function updateMobileManifest(manifestText, workspaces) { workspaces.map(({ name, version }) => [name, version]) ); const updated = []; - for (const [name, current] of Object.entries(manifest.dependencies ?? {})) { - const target = byName.get(name); - if (!target || current === target) { - continue; + 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); } - if (compareVersions(current, target) > 0) { - throw new Error( - `Refusing to downgrade ${name} from ${current} to ${target}` - ); - } - manifest.dependencies[name] = target; - updated.push(name); } return { text: @@ -62,17 +73,25 @@ async function main() { "Usage: node scripts/sync-app-monorepo.mjs " ); } - const manifestPath = join( - resolve(appMonorepoArg), - "apps/mobile/package.json" - ); const workspaces = await loadReleaseWorkspaces(repoRoot); - const original = await readFile(manifestPath, "utf8"); - const { text, updated } = updateMobileManifest(original, workspaces); - if (updated.length > 0) { - await writeFile(manifestPath, text); + 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}`); } - console.log(`Updated ${updated.length} app-monorepo mobile dependencies`); } if (process.argv[1] === fileURLToPath(import.meta.url)) { diff --git a/scripts/sync-app-monorepo.test.mjs b/scripts/sync-app-monorepo.test.mjs index ffde56525..876810733 100644 --- a/scripts/sync-app-monorepo.test.mjs +++ b/scripts/sync-app-monorepo.test.mjs @@ -6,6 +6,7 @@ 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", () => { @@ -43,6 +44,44 @@ test("does nothing when app-monorepo already uses the release", () => { }); }); +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( @@ -51,6 +90,15 @@ test("refuses to downgrade a newer app-monorepo dependency", () => { ); }); +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( () => From bb35542949010bac2495d98c9630bb2a73e37b45 Mon Sep 17 00:00:00 2001 From: huhuanming Date: Mon, 21 Sep 2026 01:53:34 +0800 Subject: [PATCH 4/7] fix: refresh CI lockfiles and find existing release PRs --- .github/workflows/package-publish.yml | 9 ++------- .github/workflows/prepare-release.yml | 4 ++-- README.md | 2 +- 3 files changed, 5 insertions(+), 10 deletions(-) diff --git a/.github/workflows/package-publish.yml b/.github/workflows/package-publish.yml index e2da40f7a..efb821c1a 100644 --- a/.github/workflows/package-publish.yml +++ b/.github/workflows/package-publish.yml @@ -135,7 +135,6 @@ jobs: owner: ${{ github.repository_owner }} repositories: app-monorepo permission-contents: read - permission-packages: read - uses: actions/checkout@v6 with: ref: ${{ github.event.pull_request.merge_commit_sha }} @@ -151,8 +150,6 @@ jobs: - uses: actions/setup-node@v6 with: node-version: '24.x' - registry-url: https://npm.pkg.github.com - scope: '@onekeyhq' - name: Update only app-modules mobile dependencies id: dependencies working-directory: app-monorepo @@ -166,11 +163,9 @@ jobs: - name: Refresh lockfile and check module registry if: steps.dependencies.outputs.changed == 'true' working-directory: app-monorepo - env: - NODE_AUTH_TOKEN: ${{ steps.read-token.outputs.token }} run: | corepack enable - yarn install --mode=skip-build + 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 @@ -206,7 +201,7 @@ jobs: 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 --head "OneKeyHQ:${branch}" --json number --jq '.[0].number') + 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 diff --git a/.github/workflows/prepare-release.yml b/.github/workflows/prepare-release.yml index 00460986b..28eadcea4 100644 --- a/.github/workflows/prepare-release.yml +++ b/.github/workflows/prepare-release.yml @@ -60,7 +60,7 @@ jobs: yarn install --immutable node --test scripts/*.test.mjs yarn version:patch - yarn install + YARN_ENABLE_IMMUTABLE_INSTALLS=false yarn install node scripts/prepare-release.mjs node scripts/validate-npm-dist-tag.mjs latest git diff --check @@ -95,7 +95,7 @@ jobs: git push origin "HEAD:refs/heads/${branch}" fi body="Automated version and changelog update for app-modules ${version}. The publish workflow will run only after this PR merges into main." - pr_number=$(gh pr list --repo "$GITHUB_REPOSITORY" --state open --head "${GITHUB_REPOSITORY_OWNER}:${branch}" --json number --jq '.[0].number') + pr_number=$(gh pr list --repo "$GITHUB_REPOSITORY" --state open --base main --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 "$GITHUB_REPOSITORY" --title "release: app-modules ${version}" --body "$body" else diff --git a/README.md b/README.md index 7f5f51b38..e3b4e137a 100644 --- a/README.md +++ b/README.md @@ -17,7 +17,7 @@ yarn package:setup new-lib Merging a same-repository PR into `main` creates or updates a release PR with synchronized package versions, `yarn.lock`, and a `CHANGELOG.md` entry. A merged fork PR is intentionally skipped with a workflow summary notice; run `prepare-release` manually when ready. Write detailed release notes under `Unreleased` in the change PR when needed; the release PR also lists every merged commit since the previous release. Once the release PR merges, `package-publish.yml` verifies the release PR was created by the configured App, publishes missing packages with the npm `latest` tag, verifies all published versions on npm, and opens an app-monorepo PR against `x` for the mobile, components, root dependency pins, and lockfile updates. The existing manual publish action remains available for `next`, `latest`, and single-workspace recovery. -Before enabling the workflows, install a GitHub App on both `app-modules` and `app-monorepo` with repository Contents (write), Pull requests (write), and Packages (read) 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 creates release and dependency PRs; its token allows the PR checks to run without the approval gate applied to PRs created with `GITHUB_TOKEN`. +Before enabling the workflows, install a GitHub App on both `app-modules` and `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 creates release and dependency PRs; its token allows the PR checks to run without the approval gate applied to PRs created with `GITHUB_TOKEN`. The current `main` ruleset requires one approval and has no bypass actor, so the release PR must be approved before automatic publication can continue. Granting a dedicated App a narrowly scoped ruleset exception is an administrator decision, not part of this workflow. The app-monorepo PR is never merged automatically. From 844713d3b6ee804fbbcca2e7158221fcbda56955 Mon Sep 17 00:00:00 2001 From: huhuanming Date: Mon, 21 Sep 2026 02:01:37 +0800 Subject: [PATCH 5/7] fix: poll npm visibility every 45 seconds up to ten times --- scripts/verify-published.mjs | 35 +++++++++++++++++-------------- scripts/verify-published.test.mjs | 30 ++++++++++++++++++++++++-- 2 files changed, 47 insertions(+), 18 deletions(-) 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; + } +}); From 42439d780211ac35be744210e8a19e2cc72f310a Mon Sep 17 00:00:00 2001 From: huhuanming Date: Mon, 21 Sep 2026 10:04:53 +0800 Subject: [PATCH 6/7] docs: clarify supported release workflow queue --- .github/workflows/package-publish.yml | 2 ++ .github/workflows/prepare-release.yml | 2 ++ 2 files changed, 4 insertions(+) diff --git a/.github/workflows/package-publish.yml b/.github/workflows/package-publish.yml index efb821c1a..165001699 100644 --- a/.github/workflows/package-publish.yml +++ b/.github/workflows/package-publish.yml @@ -28,6 +28,8 @@ permissions: 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: diff --git a/.github/workflows/prepare-release.yml b/.github/workflows/prepare-release.yml index 28eadcea4..a7ef15528 100644 --- a/.github/workflows/prepare-release.yml +++ b/.github/workflows/prepare-release.yml @@ -12,6 +12,8 @@ permissions: # A newer merge must not replace a pending release preparation. concurrency: group: app-modules-prepare-release + # Supported on GitHub.com; keep pending release runs instead of replacing them. + # https://docs.github.com/en/actions/reference/workflows-and-actions/workflow-syntax#concurrency queue: max jobs: From 8d528bfc2daf51acfdc216d4aad8db2abe0a8ee2 Mon Sep 17 00:00:00 2001 From: huhuanming Date: Mon, 21 Sep 2026 11:40:27 +0800 Subject: [PATCH 7/7] feat: publish preview or latest based on version changes --- .github/workflows/package-publish.yml | 111 ++++++++++++------------ .github/workflows/prepare-release.yml | 105 ---------------------- README.md | 6 +- scripts/detect-release-channel.mjs | 73 ++++++++++++++++ scripts/detect-release-channel.test.mjs | 33 +++++++ scripts/prepare-preview.mjs | 77 ++++++++++++++++ scripts/prepare-preview.test.mjs | 47 ++++++++++ scripts/prepare-release.mjs | 98 --------------------- scripts/publish-missing.mjs | 34 ++++++-- scripts/publish-missing.test.mjs | 14 +-- scripts/validate-npm-dist-tag.mjs | 6 ++ 11 files changed, 327 insertions(+), 277 deletions(-) delete mode 100644 .github/workflows/prepare-release.yml create mode 100644 scripts/detect-release-channel.mjs create mode 100644 scripts/detect-release-channel.test.mjs create mode 100644 scripts/prepare-preview.mjs create mode 100644 scripts/prepare-preview.test.mjs delete mode 100644 scripts/prepare-release.mjs diff --git a/.github/workflows/package-publish.yml b/.github/workflows/package-publish.yml index 165001699..45f1b4dd8 100644 --- a/.github/workflows/package-publish.yml +++ b/.github/workflows/package-publish.yml @@ -15,9 +15,6 @@ 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: "" @@ -33,99 +30,99 @@ concurrency: queue: max jobs: - verify-release-pr: + 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 && - startsWith(github.event.pull_request.head.ref, 'codex/release-app-modules-v') + github.event.pull_request.head.repo.full_name != github.repository runs-on: ubuntu-latest steps: - - name: Get the release App identity - id: app-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-modules - permission-contents: read - - name: Verify the release PR author - env: - EXPECTED_BOT: ${{ steps.app-token.outputs.app-slug }}[bot] - PR_AUTHOR: ${{ github.event.pull_request.user.login }} + - name: Explain manual release requirement run: | - test "$PR_AUTHOR" = "$EXPECTED_BOT" || { - echo "Release PR was not created by $EXPECTED_BOT" - exit 1 - } + echo 'A merged fork PR does not trigger an automatic package release. Run package-publish manually when ready.' >> "$GITHUB_STEP_SUMMARY" package-publish: - needs: verify-release-pr if: >- - always() && - (github.event_name == 'workflow_dispatch' || + github.event_name == 'workflow_dispatch' || (github.event.pull_request.merged == true && - github.event.pull_request.head.repo.full_name == github.repository && - startsWith(github.event.pull_request.head.ref, 'codex/release-app-modules-v') && - needs.verify-release-pr.result == 'success')) + github.event.pull_request.head.repo.full_name == github.repository) runs-on: ubuntu-latest + outputs: + dist_tag: ${{ steps.channel.outputs.dist_tag }} env: - NPM_DIST_TAG: ${{ github.event_name == 'pull_request' && 'latest' || inputs.npm_dist_tag }} 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 - run: node scripts/validate-npm-dist-tag.mjs "$NPM_DIST_TAG" - - name: Install Package - run: corepack enable && yarn install --immutable - - name: Match the merged release branch to the package version - if: github.event_name == 'pull_request' + - 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: - RELEASE_HEAD_REF: ${{ github.event.pull_request.head.ref }} + PREVIEW_NUMBER: ${{ github.run_number }} run: | - version=$(node -p "require('./native-views/react-native-native-list/package.json').version") - test "$RELEASE_HEAD_REF" = "codex/release-app-modules-v${version}" || { - echo 'Release branch and package version do not match' - exit 1 - } - - name: Publish missing packages after release PR merge + 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: ${{ steps.channel.outputs.dist_tag }} + run: node scripts/validate-npm-dist-tag.mjs "$NPM_DIST_TAG" + - name: Publish missing packages after merge if: github.event_name == 'pull_request' env: NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + NPM_DIST_TAG: ${{ steps.channel.outputs.dist_tag }} run: node scripts/publish-missing.mjs "$NPM_DIST_TAG" - - name: Publish packages (4 concurrent) + - name: Publish missing packages manually if: github.event_name == 'workflow_dispatch' && inputs.only_workspace == '' env: NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} - run: yarn version:publish --tag "$NPM_DIST_TAG" - - name: Publish NativeList after shared build dependencies - if: github.event_name == 'workflow_dispatch' && inputs.only_workspace == '' - env: - NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} - run: yarn workspace @onekeyfe/react-native-native-list release --tag "$NPM_DIST_TAG" - - name: Publish a single 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: ${{ 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: ${{ 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' + 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 diff --git a/.github/workflows/prepare-release.yml b/.github/workflows/prepare-release.yml deleted file mode 100644 index a7ef15528..000000000 --- a/.github/workflows/prepare-release.yml +++ /dev/null @@ -1,105 +0,0 @@ -name: prepare-release - -on: - pull_request: - types: [closed] - branches: [main] - workflow_dispatch: - -permissions: - contents: read - -# A newer merge must not replace a pending release preparation. -concurrency: - group: app-modules-prepare-release - # Supported on GitHub.com; keep pending release 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 release. Run prepare-release manually when ready.' >> "$GITHUB_STEP_SUMMARY" - - prepare-release: - if: >- - github.event_name == 'workflow_dispatch' || - (github.event.pull_request.merged == true && - github.event.pull_request.head.repo.full_name == github.repository && - !startsWith(github.event.pull_request.head.ref, 'codex/release-app-modules-v')) - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v6 - with: - ref: main - fetch-depth: 0 - persist-credentials: false - - name: Check for unreleased merges - id: changes - run: | - previous_release=$(git log -1 --format=%H --extended-regexp '--grep=^release: app-modules [0-9]+\.[0-9]+\.[0-9]+') - test -n "$previous_release" || { echo 'Previous release commit not found'; exit 1; } - if test "$(git rev-list --count "$previous_release"..HEAD)" -gt 0; then - echo 'needed=true' >> "$GITHUB_OUTPUT" - else - echo 'No changes since the previous release' - fi - - uses: actions/setup-node@v6 - if: steps.changes.outputs.needed == 'true' - with: - node-version: "24.x" - - name: Bump versions and prepare changelog - if: steps.changes.outputs.needed == 'true' - run: | - corepack enable - yarn install --immutable - node --test scripts/*.test.mjs - yarn version:patch - YARN_ENABLE_IMMUTABLE_INSTALLS=false yarn install - node scripts/prepare-release.mjs - node scripts/validate-npm-dist-tag.mjs latest - git diff --check - - name: Create repository-scoped release token - if: steps.changes.outputs.needed == 'true' - id: app-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-modules - permission-contents: write - permission-pull-requests: write - - name: Create or update release PR - if: steps.changes.outputs.needed == 'true' - env: - GH_TOKEN: ${{ steps.app-token.outputs.token }} - run: | - version=$(node -p "require('./native-views/react-native-native-list/package.json').version") - branch="codex/release-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 CHANGELOG.md yarn.lock native-modules native-views - git diff --cached --check - git commit -m "release: app-modules ${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="Automated version and changelog update for app-modules ${version}. The publish workflow will run only after this PR merges into main." - pr_number=$(gh pr list --repo "$GITHUB_REPOSITORY" --state open --base main --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 "$GITHUB_REPOSITORY" --title "release: app-modules ${version}" --body "$body" - else - gh pr create --repo "$GITHUB_REPOSITORY" --base main --head "$branch" --title "release: app-modules ${version}" --body "$body" - fi diff --git a/README.md b/README.md index e3b4e137a..a0c625e0a 100644 --- a/README.md +++ b/README.md @@ -15,10 +15,10 @@ yarn package:setup new-lib ## Release automation -Merging a same-repository PR into `main` creates or updates a release PR with synchronized package versions, `yarn.lock`, and a `CHANGELOG.md` entry. A merged fork PR is intentionally skipped with a workflow summary notice; run `prepare-release` manually when ready. Write detailed release notes under `Unreleased` in the change PR when needed; the release PR also lists every merged commit since the previous release. Once the release PR merges, `package-publish.yml` verifies the release PR was created by the configured App, publishes missing packages with the npm `latest` tag, verifies all published versions on npm, and opens an app-monorepo PR against `x` for the mobile, components, root dependency pins, and lockfile updates. The existing manual publish action remains available for `next`, `latest`, and single-workspace recovery. +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. -Before enabling the workflows, install a GitHub App on both `app-modules` and `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 creates release and dependency PRs; its token allows the PR checks to run without the approval gate applied to PRs created with `GITHUB_TOKEN`. +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. -The current `main` ruleset requires one approval and has no bypass actor, so the release PR must be approved before automatic publication can continue. Granting a dedicated App a narrowly scoped ruleset exception is an administrator decision, not part of this workflow. The app-monorepo PR is never merged automatically. +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. 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/prepare-release.mjs b/scripts/prepare-release.mjs deleted file mode 100644 index 6f409681b..000000000 --- a/scripts/prepare-release.mjs +++ /dev/null @@ -1,98 +0,0 @@ -import { execFileSync } from "node:child_process"; -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 changelogPath = join(repoRoot, "CHANGELOG.md"); - -export function renderReleaseChangelog( - changelog, - { version, date, commitTitles, workspaceCount } -) { - const marker = "## [Unreleased]\n"; - const markerStart = changelog.indexOf(marker); - if (markerStart < 0 || changelog.indexOf(marker, markerStart + 1) >= 0) { - throw new Error("CHANGELOG.md must contain exactly one Unreleased section"); - } - if (changelog.includes(`## [${version}]`)) { - throw new Error(`CHANGELOG.md already contains ${version}`); - } - if (commitTitles.length === 0) { - throw new Error("No merged commits since the previous release"); - } - - const bodyStart = markerStart + marker.length; - const remaining = changelog.slice(bodyStart); - const nextHeading = remaining.search(/^## \[/m); - if (nextHeading < 0) { - throw new Error("CHANGELOG.md has no previous release section"); - } - const unreleased = remaining.slice(0, nextHeading).trim(); - const previousReleases = remaining.slice(nextHeading); - const mergedChanges = commitTitles.map((title) => `- ${title}`).join("\n"); - const releaseBody = [ - unreleased, - `### Merged changes\n${mergedChanges}`, - `### Version\n- Bump all ${workspaceCount} publishable packages to ${version}.`, - ] - .filter(Boolean) - .join("\n\n"); - - return ( - changelog.slice(0, bodyStart) + - `\n## [${version}] - ${date}\n\n${releaseBody}\n\n` + - previousReleases - ); -} - -function git(...args) { - return execFileSync("git", args, { cwd: repoRoot, encoding: "utf8" }).trim(); -} - -async function main() { - const previousRelease = git( - "log", - "-1", - "--format=%H", - "--extended-regexp", - "--grep=^release: app-modules [0-9]+\\.[0-9]+\\.[0-9]+" - ); - if (!previousRelease) { - throw new Error("Cannot find the previous app-modules release commit"); - } - const commitTitles = git( - "log", - "--reverse", - "--format=%s", - `${previousRelease}..HEAD` - ) - .split("\n") - .filter(Boolean); - const workspaces = await loadReleaseWorkspaces(repoRoot); - const versions = new Set(workspaces.map(({ version }) => version)); - if (versions.size !== 1 || workspaces.length === 0) { - throw new Error("Publishable workspace versions must match before release"); - } - const [version] = versions; - const changelog = await readFile(changelogPath, "utf8"); - const updated = renderReleaseChangelog(changelog, { - version, - date: new Date().toISOString().slice(0, 10), - commitTitles, - workspaceCount: workspaces.length, - }); - await writeFile(changelogPath, updated); - console.log( - `Prepared CHANGELOG.md for ${version} (${commitTitles.length} commits)` - ); -} - -if (process.argv[1] === fileURLToPath(import.meta.url)) { - main().catch((error) => { - console.error(error.message); - process.exitCode = 1; - }); -} diff --git a/scripts/publish-missing.mjs b/scripts/publish-missing.mjs index e6568ffb0..a70cdfdfb 100644 --- a/scripts/publish-missing.mjs +++ b/scripts/publish-missing.mjs @@ -36,13 +36,7 @@ export function selectUnpublished(workspaces, packuments, distTag) { ); } if (document?.versions?.[workspace.version]) { - if (tagged !== workspace.version) { - throw new Error( - `${workspace.name}@${ - workspace.version - } already exists but ${distTag} points at ${tagged ?? "nothing"}` - ); - } + continue; } else { missing.push(workspace); } @@ -50,6 +44,16 @@ export function selectUnpublished(workspaces, packuments, distTag) { 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" }, @@ -73,6 +77,16 @@ function runYarn(args) { } } +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); @@ -88,8 +102,9 @@ async function main() { packuments.set(name, await readPackument(name)); } const missing = selectUnpublished(workspaces, packuments, distTag); + const retags = selectRetags(workspaces, packuments, distTag); console.log( - `${missing.length} of ${workspaces.length} packages still need publishing` + `${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) { @@ -112,6 +127,9 @@ async function main() { 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)) { diff --git a/scripts/publish-missing.test.mjs b/scripts/publish-missing.test.mjs index 9ee164022..d0984d367 100644 --- a/scripts/publish-missing.test.mjs +++ b/scripts/publish-missing.test.mjs @@ -1,7 +1,7 @@ import assert from "node:assert/strict"; import test from "node:test"; -import { selectUnpublished } from "./publish-missing.mjs"; +import { selectRetags, selectUnpublished } from "./publish-missing.mjs"; const workspaces = [ { name: "@onekeyfe/module-a", version: "3.0.152" }, @@ -21,17 +21,19 @@ test("skips already published packages on a retry", () => { ]); }); -test("refuses an existing version with the wrong dist-tag", () => { +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.throws( - () => selectUnpublished(workspaces, packuments, "latest"), - /already exists but latest points at 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", () => { 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, }); } }