From 6e52af27624d687ce523676e126e41848617f71a Mon Sep 17 00:00:00 2001 From: fogtape Date: Tue, 13 Oct 2026 14:17:22 +0800 Subject: [PATCH] release: prepare guarded Node 24.22 snapshot publication [skip ci] --- .github/actions/release-check/action.yml | 4 + .github/workflows/build.yml | 98 +++++++++++++++------ .github/workflows/cut-release.yml | 13 ++- docs/CHANGELOG.md | 32 +++++-- docs/RELEASING.md | 16 ++++ scripts/release-control.py | 67 +++++++++++--- scripts/test-release-control.py | 107 +++++++++++++++++++---- 7 files changed, 270 insertions(+), 67 deletions(-) diff --git a/.github/actions/release-check/action.yml b/.github/actions/release-check/action.yml index 95bd846ec9..c036eded3f 100644 --- a/.github/actions/release-check/action.yml +++ b/.github/actions/release-check/action.yml @@ -10,6 +10,9 @@ inputs: version: description: Exact mobile version required for manual prerelease requests default: '' + upstream_snapshot: + description: Explicit full SHA opt-in for an unreleased upstream proposal prerelease + default: '' token: description: Token with contents:read on this repository, for the tag probe. required: true @@ -38,4 +41,5 @@ runs: GH_TOKEN: ${{ inputs.token }} RELEASE_OPERATION: ${{ inputs.operation }} RELEASE_VERSION: ${{ inputs.version }} + UPSTREAM_SNAPSHOT: ${{ inputs.upstream_snapshot }} run: python3 scripts/release-control.py check diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 61c7f1d778..d9dbcbeaee 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -22,6 +22,10 @@ on: description: Exact mobile version for prerelease/dryrun (24.21.0-1); empty for build type: string default: '' + upstream_snapshot: + description: Optional full upstream SHA; explicitly publish an unreleased proposal prerelease + type: string + default: '' # One run per PR/branch. cancel-in-progress ONLY for pull_request: a push to # recipe may be carrying the release gate chain and must never be cancelled @@ -159,6 +163,7 @@ jobs: token: ${{ github.token }} operation: ${{ inputs.operation || 'build' }} version: ${{ inputs.version || '' }} + upstream_snapshot: ${{ inputs.upstream_snapshot || '' }} - uses: ./.github/actions/materialize @@ -342,6 +347,7 @@ jobs: token: ${{ github.token }} operation: ${{ inputs.operation || 'build' }} version: ${{ inputs.version || '' }} + upstream_snapshot: ${{ inputs.upstream_snapshot || '' }} - uses: ./.github/actions/materialize @@ -729,6 +735,7 @@ jobs: token: ${{ github.token }} operation: ${{ inputs.operation || 'build' }} version: ${{ inputs.version || '' }} + upstream_snapshot: ${{ inputs.upstream_snapshot || '' }} - uses: ./.github/actions/materialize @@ -990,6 +997,7 @@ jobs: token: ${{ github.token }} operation: ${{ inputs.operation || 'build' }} version: ${{ inputs.version || '' }} + upstream_snapshot: ${{ inputs.upstream_snapshot || '' }} - name: Validate publishable notes before compiling if: steps.d.outputs.cold == 'true' env: @@ -1249,6 +1257,7 @@ jobs: env: DRY: ${{ needs.release-check.outputs.dryrun == 'true' && '1' || '0' }} V: ${{ needs.release-check.outputs.version }} + UPSTREAM_SNAPSHOT: ${{ inputs.upstream_snapshot || '' }} GH_TOKEN: ${{ github.token }} steps: - uses: actions/checkout@v7 @@ -1270,30 +1279,6 @@ jobs: - uses: ./.github/actions/materialize - # Note: the materialized tree is a SHALLOW clone of nodejs/node (prepare.sh - # uses --depth 1), so this pushes a tag whose history is grafted at the - # upstream base, and git sends only the commits above that boundary. The - # remote accepts it only because the base and its ancestry are already - # there, on the upstream-base branch that the upstream-base job (needs:, - # above) has verified. - - name: Tag the materialized tree - run: | - git config user.name "nodejs-mobile-bot" - git config user.email "bot@users.noreply.github.com" - TAG="v${V}" - # Recipe-Commit records which recipe-branch commit generated this - # release. The tag object is the durable home for that link: the run - # that knows it expires, and the materialized commit itself carries - # only the generic overlay message. - git tag -a "$TAG" -m "nodejs-mobile ${V}" -m "Recipe-Commit: ${GITHUB_SHA}" - git remote add fork "https://x-access-token:${GH_TOKEN}@github.com/${GITHUB_REPOSITORY}.git" - if [ "$DRY" = "1" ]; then - echo "DRY RUN: would push tag $TAG (materialized tree $(git rev-parse 'HEAD^{tree}'))" - else - git push fork "refs/tags/$TAG" - echo "Pushed tag $TAG" - fi - - name: Download + package this run's artifacts run: | mkdir -p dl out @@ -1321,21 +1306,76 @@ jobs: pack nodejs-mobile-android-lite nodejs-mobile-android-lite pack nodejs-mobile-ios nodejs-mobile-ios pack nodejs-mobile-ios-lite nodejs-mobile-ios-lite + (cd out && sha256sum nodejs-mobile-*.zip > SHA256SUMS) ls -la out + - name: Tag the materialized tree + run: | + git config user.name "nodejs-mobile-bot" + git config user.email "bot@users.noreply.github.com" + TAG="v${V}" + # Recipe-Commit records which recipe-branch commit generated this + # release. The tag object is the durable home for that link: the run + # that knows it expires, and the materialized commit itself carries + # only the generic overlay message. + # This mirror need not hold upstream's entire commit ancestry. + # A source snapshot commit preserves the verified tree without + # publishing a shallow history whose parents the remote lacks. + TREE=$(git rev-parse 'HEAD^{tree}') + BASE=$(awk '/^#define NODE_MAJOR_VERSION / {ma=$3} /^#define NODE_MINOR_VERSION / {mi=$3} /^#define NODE_PATCH_VERSION / {pa=$3} END {print "v" ma "." mi "." pa}' src/node_version.h) + git remote add fork "https://x-access-token:${GH_TOKEN}@github.com/${GITHUB_REPOSITORY}.git" + EXISTING=$(git ls-remote fork "refs/tags/$TAG") + if [ -n "$EXISTING" ]; then + git fetch fork "refs/tags/$TAG:refs/tags/$TAG" + [ "$(git rev-parse "$TAG^{tree}")" = "$TREE" ] && + git cat-file tag "$TAG" | grep -Fxq "Recipe-Commit: ${GITHUB_SHA}" || { + echo "::error::Existing $TAG belongs to a different source tree or recipe commit" + exit 1 + } + else + SOURCE_COMMIT=$(printf '%s\n\n%s\n%s\n%s\n' "nodejs-mobile ${V} source snapshot" "Upstream: ${BASE}" "Upstream-Snapshot: ${UPSTREAM_SNAPSHOT:-none}" "Recipe-Commit: ${GITHUB_SHA}" | git commit-tree "$TREE") + git tag -a "$TAG" "$SOURCE_COMMIT" -m "nodejs-mobile ${V}" -m "Recipe-Commit: ${GITHUB_SHA}" -m "Source-Tree: ${TREE}" -m "Upstream-Snapshot: ${UPSTREAM_SNAPSHOT:-none}" + fi + if [ "$DRY" = "1" ]; then + echo "DRY RUN: would push tag $TAG (materialized tree $(git rev-parse 'HEAD^{tree}'))" + else + git push fork "refs/tags/$TAG" + echo "Pushed tag $TAG" + fi + - name: Create GitHub Release (prerelease) run: | TAG="v${V}" - RELEASE_TITLE="Node.js 24 LTS · nodejs-mobile v${V}(预发布)" + MAJOR="${V%%.*}" + if [ "$MAJOR" = 24 ]; then + LINE="Node.js 24 LTS" + else + LINE="Node.js ${MAJOR}" + fi + RELEASE_TITLE="${LINE} · nodejs-mobile v${V} (Prerelease)" + if [ -n "$UPSTREAM_SNAPSHOT" ]; then + RELEASE_TITLE="${LINE} · nodejs-mobile v${V} (Upstream PR Snapshot Prerelease)" + python3 - <<'PY' + import os + from pathlib import Path + path = Path(os.environ['RUNNER_TEMP']) / 'release-notes.md' + sha = os.environ['UPSTREAM_SNAPSHOT'] + notice = ("**Upstream proposal snapshot:** this prerelease is built from an " + "unreleased Node.js proposal, not an official Node.js release tag. " + f"Pinned upstream commit: [{sha}](https://github.com/nodejs/node/commit/{sha}). " + "The final upstream release may differ.\n\n") + path.write_text(notice + path.read_text()) + PY + fi NOTES=$(cat "$RUNNER_TEMP/release-notes.md") # Human-visible copy of the tag's Recipe-Commit trailer. - PROV="Generated from [\`recipe@${GITHUB_SHA:0:10}\`](https://github.com/${GITHUB_REPOSITORY}/commit/${GITHUB_SHA})." + PROV="Generated from [\`${GITHUB_REF_NAME}@${GITHUB_SHA:0:10}\`](https://github.com/${GITHUB_REPOSITORY}/commit/${GITHUB_SHA})." if [ "$DRY" = "1" ]; then echo "DRY RUN: would publish prerelease $TAG with:" ls -la out printf '%s\n' "--- notes ---" "$NOTES" "" "$PROV" else - BODY="$(printf '%s\n\n%s' "${NOTES:-Release ${V}. See https://github.com/${GITHUB_REPOSITORY}/blob/recipe/docs/CHANGELOG.md}" "$PROV")" + printf '%s\n\n%s\n' "$NOTES" "$PROV" > "$RUNNER_TEMP/publish-notes.md" # Draft first, flip live once the assets are attached: with # immutable releases enabled a release rejects asset uploads the # moment it is published, and `gh release create` publishes @@ -1347,13 +1387,13 @@ jobs: gh release delete "$TAG" --yes --repo "$GITHUB_REPOSITORY" echo "Deleted leftover draft for $TAG" fi - gh release create "$TAG" out/*.zip \ + gh release create "$TAG" out/*.zip out/SHA256SUMS \ --repo "$GITHUB_REPOSITORY" \ --title "$RELEASE_TITLE" \ --draft \ --prerelease \ --latest=false \ - --notes "$BODY" + --notes-file "$RUNNER_TEMP/publish-notes.md" gh release edit "$TAG" --repo "$GITHUB_REPOSITORY" --draft=false echo "Published prerelease $TAG" fi diff --git a/.github/workflows/cut-release.yml b/.github/workflows/cut-release.yml index 4fe465dc72..22333216d4 100644 --- a/.github/workflows/cut-release.yml +++ b/.github/workflows/cut-release.yml @@ -16,6 +16,10 @@ on: type: string default: auto required: true + upstream_snapshot: + description: Optional full upstream SHA; explicitly prepare an unreleased proposal prerelease + type: string + default: '' operation: description: Preview the plan, or open a release review PR type: choice @@ -52,9 +56,11 @@ jobs: env: RELEASE_LINE: ${{ inputs.release_line }} REQUESTED_VERSION: ${{ inputs.version }} + UPSTREAM_SNAPSHOT: ${{ inputs.upstream_snapshot }} run: | python3 scripts/release-control.py plan --root release-source \ --line "$RELEASE_LINE" --version "$REQUESTED_VERSION" \ + --upstream-snapshot "$UPSTREAM_SNAPSHOT" \ --out "$RUNNER_TEMP/release-plan.json" python3 - <<'PY' import json, os, subprocess @@ -69,6 +75,7 @@ jobs: f"- Line: `{data['line']}`\n" f"- Source: `{data['source_commit']}`\n" f"- Upstream: `{data['upstream']}`\n" + f"- Unreleased upstream snapshot: `{data['upstream_snapshot'] or 'none'}`\n" f"- Tag: `{data['tag']}`\n" "- GitHub prerelease: **yes**, latest: **no**\n\n" "Plan only: no tag, release or source mutation. " @@ -103,18 +110,21 @@ jobs: RELEASE_LINE: ${{ steps.v.outputs.line }} RELEASE_BRANCH: ${{ steps.v.outputs.release_branch }} V: ${{ steps.v.outputs.version }} + UPSTREAM_SNAPSHOT: ${{ steps.v.outputs.upstream_snapshot }} run: | set -euo pipefail git config user.name "nodejs-mobile-bot" git config user.email "bot@users.noreply.github.com" git checkout -b "$RELEASE_BRANCH" - git add mobile-src/src/node_mobile_version.h docs/CHANGELOG.md expected-tree.txt release-ready.txt + git add mobile-src/src/node_mobile_version.h docs/CHANGELOG.md expected-tree.txt release-ready.txt release-upstream-snapshot.txt git commit -m "release: prepare nodejs-mobile ${V} prerelease" # No force push; existing release branches are reserved in plan. git push origin "$RELEASE_BRANCH" cat > "$RUNNER_TEMP/release-pr.md" < ## Unreleased, Version 24.22.0-0 (Current) -### Notable Changes - -- **Engine:** preview Node.js 24.22.0 LTS release proposal [nodejs/node#66667](https://github.com/nodejs/node/pull/66667), pinned to `a5d0eb95dd03b90986ad1a7c0cd3f2de1a0f383c`. This is an upstream proposal snapshot, not an official Node.js release tag. -- **Lite:** retain the reviewed danmu ICU data profile, feature omissions, Android 64-bit pointer compression and ABI-preserving SDK symbol stripping. Both flavors keep the full ICU API; iOS keeps jitless V8 and polywasm without pointer compression. -- **Mobile compatibility:** use upstream's new `V8_ENABLE_WEBASSEMBLY` guard for JSPI and preserve the remaining platform patches. -- **Tests/builds:** add 26 in-process upstream regressions to the curated gate, triage new child-process/permission cases, and build both iOS flavors on upgrade PRs. Reuse the bounded Android emulator shards already validated on the Node 26 line. -- **Artifacts:** successful upgrade builds package Android/iOS full/lite previews with source metadata and `SHA256SUMS`. Ordinary builds do not publish a release. -- **Device regressions:** preserve Intl and SQLite backup in-process coverage while skipping only unsupported child probes; use the observed mobile filesystem error and exclude a native WASM flag test only on iOS. These three runnable regressions also join the curated gate. +This prerelease updates the mobile runtime from Node.js 24.21.0 to the +Node.js 24.22.0 LTS release proposal [nodejs/node#66667](https://github.com/nodejs/node/pull/66667), +pinned to `a5d0eb95dd03b90986ad1a7c0cd3f2de1a0f383c`. It is built from an +upstream proposal snapshot; the final official Node.js release may differ. + +### Runtime and flavor changes + +- **Full:** embed complete ICU data for locale formatting, collation and legacy text decoding. The previous 24.21.0-0 full release used small-ICU; complete ICU data accounts for most of the increased binary size. +- **Lite:** retain the reviewed `danmu_api` ICU data profile. Both flavors keep the complete ICU API; lite preserves all Node TextDecoder web encodings (including GBK, GB2312 and Big5), English/Chinese/Cantonese/Japanese/Korean locale families, Unicode normalization, IDNA, time zones and segmentation. Other locale-specific formatting may fall back to a retained locale. +- **Lite omissions:** omit Amaro TypeScript stripping, Inspector, SQLite/Web Storage, single-executable application support and native V8 object-print helpers. Keep HTTP/HTTPS/TLS, crypto, fetch/Undici, filesystem and module loading, streams, compression and WebAssembly support. +- **Android:** retain JIT and native WebAssembly. Lite uses V8 pointer compression on 64-bit targets and strips its SDK libraries only after verifying that dynamic exports remain unchanged. Direct V8 native addons must match the selected flavor's ABI. +- **iOS:** retain jitless V8 and the polywasm WebAssembly implementation, without pointer compression. Both packages include arm64 device and arm64 simulator slices. +- **Mobile compatibility:** rebase the platform patches onto the proposed LTS source and adopt upstream's `V8_ENABLE_WEBASSEMBLY` guard. + +### Validation and downloads + +- Build Android arm64-v8a, armeabi-v7a and x86_64 libraries and iOS device/simulator frameworks in both full and lite flavors. +- Require host checks, boot and native-addon smokes, full/lite ICU checks, curated device tests and complete runnable Android/iOS suites in the publication workflow. Runtime validation uses Android emulators and iOS simulators; this release does not claim physical-device validation. +- Fix Android launch failures before Node starts and improve iOS smoke launch and verdict collection. Preserve in-process Intl and SQLite regression coverage while skipping unsupported mobile child-process probes. +- Provide four SDK archives with headers: `nodejs-mobile-android-24.22.0-0.zip`, `nodejs-mobile-android-lite-24.22.0-0.zip`, `nodejs-mobile-ios-24.22.0-0.zip` and `nodejs-mobile-ios-lite-24.22.0-0.zip`, plus `SHA256SUMS`. + +For `danmu_api`, use the lite package when its retained feature and locale +profile matches your application. Verify the selected archive against +`SHA256SUMS` before integration. This source upgrade does not arm publication. diff --git a/docs/RELEASING.md b/docs/RELEASING.md index 8c0b861a00..b45de03719 100644 --- a/docs/RELEASING.md +++ b/docs/RELEASING.md @@ -86,6 +86,22 @@ have FFI and no ICU; the older 26.1 release disables both features. iOS uses jitless V8 and the bundled WebAssembly polyfill. See [VERSION-LINES.md](VERSION-LINES.md) for compatibility limits. +## Upstream proposal snapshot prereleases + +An unreleased upstream proposal can be published as a GitHub prerelease only +with explicit `upstream_snapshot` input in both manual workflows. Supply the +full SHA already pinned in `upstream-base.txt`; it cannot select a different +commit. The controller reads `src/node_version.h` from official `nodejs/node` +to verify the selected version and line. + +Cut release records both the version and exact snapshot SHA in the reviewed +release PR. After merging it, dispatch Build on the same maintained line with +`operation=prerelease`, that exact version, and the same `upstream_snapshot`. +All ordinary build and release gates still apply. The release title and English +body identify the upstream proposal snapshot and link the immutable commit. +The final official upstream release may differ. This does not alter the other +maintained Node major line. + ## Source tags and provenance Tags point at full-source snapshot commits whose tree matches diff --git a/scripts/release-control.py b/scripts/release-control.py index d3c5c838ba..aa54f38259 100644 --- a/scripts/release-control.py +++ b/scripts/release-control.py @@ -2,6 +2,8 @@ """Plan reviewed release PRs and validate explicit manual prerelease requests.""" import argparse +import base64 +import binascii import datetime import json import os @@ -34,14 +36,40 @@ def version_text(parts): f'-{parts[3]}' if len(parts) == 4 else '') -def upstream(root): +def snapshot_version(sha): + process = subprocess.run( + ['gh', 'api', f'repos/nodejs/node/contents/src/node_version.h?ref={sha}', + '--jq', '.content'], capture_output=True, text=True) + if process.returncode: + raise ReleaseError('Cannot verify the snapshot version on official nodejs/node. Retry later.') + try: + source = base64.b64decode(''.join(process.stdout.split()), validate=True).decode('ascii') + except (ValueError, binascii.Error, UnicodeError) as error: + raise ReleaseError('Official snapshot version header is invalid') from error + parts = [] + for field in ('MAJOR', 'MINOR', 'PATCH'): + matches = re.findall(rf'^#define NODE_{field}_VERSION\s+([0-9]+)\s*$', source, re.M) + if len(matches) != 1: + raise ReleaseError(f'Expected one NODE_{field}_VERSION in the official snapshot') + parts.append(int(matches[0])) + return tuple(parts) + + +def upstream(root, snapshot=''): lines = [line.strip() for line in (root / 'upstream-base.txt').read_text().splitlines() if line.strip() and not line.lstrip().startswith('#')] if len(lines) != 1: raise ReleaseError('upstream-base.txt must pin exactly one upstream ref') if re.fullmatch(r'[0-9a-f]{40}', lines[0]): - raise ReleaseError('This source pins an unreleased upstream PR commit. ' - 'Build a CI preview; pin the official release tag before Cut release or publication.') + if not snapshot: + raise ReleaseError('This source pins an unreleased upstream PR commit. ' + 'Pin an official release tag, or explicitly provide upstream_snapshot ' + 'with this exact SHA to prepare/publish an upstream snapshot prerelease.') + if snapshot != lines[0]: + raise ReleaseError('upstream_snapshot must exactly match the full SHA in upstream-base.txt') + return snapshot_version(snapshot) + if snapshot: + raise ReleaseError('upstream_snapshot is only valid for a SHA-pinned upstream proposal') return parse_version(lines[0]) @@ -75,8 +103,8 @@ def require_line(line, base): raise ReleaseError(f'{line} is the Node {LINES[line]} line, but its source pins Node {base[0]}') -def plan(root, line, requested, exists): - base = upstream(root) +def plan(root, line, requested, exists, snapshot=''): + base = upstream(root, snapshot) require_line(line, base) if requested == 'auto': selected, revision = base, None @@ -104,13 +132,14 @@ def taken(candidate): 'revision or review the existing release PR. Nothing will be overwritten.') return {'line': line, 'upstream': 'v' + version_text(base), 'version': version, 'tag': 'v' + version, 'release_branch': 'release/v' + version, - 'prerelease': True, 'latest': False} + 'prerelease': True, 'latest': False, 'upstream_snapshot': snapshot} def prepare(root, release_plan): version = release_plan['version'] + snapshot = release_plan.get('upstream_snapshot', '') parts = parse_version(version, mobile=True) - if parts[:3] != upstream(root): + if parts[:3] != upstream(root, snapshot): raise ReleaseError('The release plan does not match the checked-out source') header = root / 'mobile-src/src/node_mobile_version.h' source = header.read_text() @@ -132,7 +161,8 @@ def prepare(root, release_plan): if not heading.startswith('## ') or f'Version {version}' not in heading: raise ReleaseError('Existing CHANGELOG entry has an unexpected heading') body = original.lstrip('\n') - body = body.replace('This source upgrade does not arm publication.\n', '') + body = re.sub(r'^[ \t]*This source upgrade does not arm publication\.\n', + '', body, flags=re.M) if not body.strip(): body = '- _TODO: summarize changes before publishing._\n\n' notes = section.sub('', notes) @@ -152,9 +182,10 @@ def prepare(root, release_plan): header.write_text(source) changelog.write_text(notes) (root / 'release-ready.txt').write_text(version + '\n') + (root / 'release-upstream-snapshot.txt').write_text(snapshot + '\n' if snapshot else '') -def check(root, event, ref, operation, requested, exists): +def check(root, event, ref, operation, requested, exists, snapshot=''): version = version_text(recorded_version(root)) answer = dict(version=version, release='false', dryrun='false', cold='false') # PR/push builds never publish, regardless of a marker or commit message. @@ -163,13 +194,14 @@ def check(root, event, ref, operation, requested, exists): if operation not in ('build', 'prerelease-dryrun', 'prerelease'): raise ReleaseError('Unknown operation; choose build, prerelease-dryrun or prerelease') if operation == 'build': - if requested: - raise ReleaseError('The version input is for prerelease/dryrun only; clear it for a build') + if requested or snapshot: + raise ReleaseError('The version and upstream_snapshot inputs are for prerelease/dryrun only; ' + 'clear them for a build') return answer line = ref.removeprefix('refs/heads/') if ref != 'refs/heads/' + line: raise ReleaseError('Prereleases must run on a maintained branch, not a tag') - base = upstream(root) + base = upstream(root, snapshot) require_line(line, base) selected = version_text(parse_version(requested, mobile=True)) if selected != version or parse_version(version, mobile=True)[:3] != base: @@ -178,6 +210,10 @@ def check(root, event, ref, operation, requested, exists): marker = root / 'release-ready.txt' if not marker.is_file() or marker.read_text().strip() != version: raise ReleaseError(f'{version} is not armed: merge its reviewed Cut release PR first') + if snapshot: + snapshot_marker = root / 'release-upstream-snapshot.txt' + if not snapshot_marker.is_file() or snapshot_marker.read_text().strip() != snapshot: + raise ReleaseError('The reviewed Cut release PR must arm this exact upstream snapshot SHA') for tag in ('v' + version, 'nodejs-mobile-' + version): if exists('tags', tag): raise ReleaseError(f'Tag {tag} already exists; inspect/resume its original publish ' @@ -204,6 +240,7 @@ def main(): planner.add_argument('--line', required=True, choices=LINES) planner.add_argument('--version', default='auto') planner.add_argument('--out', type=Path, required=True) + planner.add_argument('--upstream-snapshot', default='') preparer = sub.add_parser('prepare') preparer.add_argument('--root', type=Path, default=Path('.')) preparer.add_argument('--plan', type=Path, required=True) @@ -213,17 +250,19 @@ def main(): checker.add_argument('--ref', default=os.environ.get('GITHUB_REF', '')) checker.add_argument('--operation', default=os.environ.get('RELEASE_OPERATION', 'build')) checker.add_argument('--version', default=os.environ.get('RELEASE_VERSION', '')) + checker.add_argument('--upstream-snapshot', default=os.environ.get('UPSTREAM_SNAPSHOT', '')) args = parser.parse_args() exists = lambda kind, name: ref_exists(os.environ.get('GITHUB_REPOSITORY', ''), kind, name) try: if args.command == 'plan': - result = plan(args.root, args.line, args.version, exists) + result = plan(args.root, args.line, args.version, exists, args.upstream_snapshot) args.out.write_text(json.dumps(result, indent=2) + '\n') emit(result) elif args.command == 'prepare': prepare(args.root, json.loads(args.plan.read_text())) else: - emit(check(args.root, args.event, args.ref, args.operation, args.version, exists)) + emit(check(args.root, args.event, args.ref, args.operation, args.version, exists, + args.upstream_snapshot)) except (ReleaseError, FileNotFoundError) as error: print(f'::error::{error}', file=sys.stderr) return 1 diff --git a/scripts/test-release-control.py b/scripts/test-release-control.py index 9b1b9b74ec..f96525276a 100644 --- a/scripts/test-release-control.py +++ b/scripts/test-release-control.py @@ -2,6 +2,7 @@ """Release version selection, API failure handling and manual publish guards.""" import importlib.util +import base64 import json import os from pathlib import Path @@ -39,7 +40,8 @@ def fixture(self, version='26.10.0-0'): ## Unreleased, Version {version} (Current) - Temporal and FFI. -This source upgrade does not arm publication. + This source upgrade does not arm publication. +- Preserve the next release-note bullet. ## 2026-05-01, Version 26.1.0-0 @@ -47,12 +49,12 @@ def fixture(self, version='26.10.0-0'): - Previous release. ''') - def plan(self, version='auto', line='recipe-v26'): - return CONTROL.plan(self.root, line, version, self.exists) + def plan(self, version='auto', line='recipe-v26', snapshot=''): + return CONTROL.plan(self.root, line, version, self.exists, snapshot) def check(self, *, event='workflow_dispatch', ref='refs/heads/recipe-v26', - operation='prerelease', version='26.10.0-0'): - return CONTROL.check(self.root, event, ref, operation, version, self.exists) + operation='prerelease', version='26.10.0-0', snapshot=''): + return CONTROL.check(self.root, event, ref, operation, version, self.exists, snapshot) def arm(self, version='26.10.0-0'): (self.root / 'release-ready.txt').write_text(version + '\n') @@ -70,12 +72,84 @@ def test_auto_and_explicit_upstream_choose_free_revision(self): def test_choose_exact_unused_version(self): self.assertEqual(self.plan('v26.10.0-7')['version'], '26.10.0-7') + def test_upstream_pr_snapshot_builds_but_cannot_arm_or_publish(self): + (self.root / 'upstream-base.txt').write_text('7f68d75ee7963cd38ddc3783dd529f826bbde614\n') + for event in ('push', 'pull_request', 'workflow_dispatch'): + with self.subTest(event=event): + result = self.check(event=event, operation='build', version='') + self.assertEqual(result['release'], 'false') + with self.assertRaisesRegex(CONTROL.ReleaseError, 'unreleased upstream PR'): + self.plan() + for operation in ('prerelease', 'prerelease-dryrun'): + with self.subTest(operation=operation), self.assertRaisesRegex( + CONTROL.ReleaseError, 'unreleased upstream PR'): + self.check(operation=operation) + def test_plan_never_mutates_source(self): before = {p.relative_to(self.root): p.read_bytes() for p in self.root.rglob('*') if p.is_file()} self.plan('26.10.0-0') after = {p.relative_to(self.root): p.read_bytes() for p in self.root.rglob('*') if p.is_file()} self.assertEqual(before, after) + def test_snapshot_requires_exact_manual_opt_in_and_reviewed_sha_marker(self): + sha = '49072a0be4c7410982557aebf5b5c924fe7db597' + (self.root / 'upstream-base.txt').write_text(sha + '\n') + with patch.object(CONTROL, 'snapshot_version', return_value=(26, 10, 0)): + for invalid in ('', sha[:10], '0' * 40, sha + ';echo bad'): + with self.subTest(invalid=invalid), self.assertRaises(CONTROL.ReleaseError): + self.plan(snapshot=invalid) + release_plan = self.plan('26.10.0-0', snapshot=sha) + self.assertEqual(release_plan['upstream_snapshot'], sha) + self.arm() + with self.assertRaisesRegex(CONTROL.ReleaseError, 'reviewed Cut release PR'): + self.check(snapshot=sha) + CONTROL.prepare(self.root, release_plan) + self.assertEqual((self.root / 'release-upstream-snapshot.txt').read_text(), sha + '\n') + for operation in ('prerelease', 'prerelease-dryrun'): + result = self.check(snapshot=sha, operation=operation) + self.assertEqual(result['cold'], 'true') + self.assertEqual(result['release'], 'true' if operation == 'prerelease' else 'false') + (self.root / 'release-upstream-snapshot.txt').write_text('0' * 40 + '\n') + with self.assertRaises(CONTROL.ReleaseError): + self.check(snapshot=sha) + + def test_snapshot_version_is_verified_against_official_source(self): + sha = '4' * 40 + header = b'#define NODE_MAJOR_VERSION 26\n#define NODE_MINOR_VERSION 11\n#define NODE_PATCH_VERSION 0\n' + response = subprocess.CompletedProcess([], 0, base64.b64encode(header).decode() + '\n', '') + with patch.object(CONTROL.subprocess, 'run', return_value=response) as run: + self.assertEqual(CONTROL.snapshot_version(sha), (26, 11, 0)) + self.assertIn(f'repos/nodejs/node/contents/src/node_version.h?ref={sha}', run.call_args[0][0]) + for response in (subprocess.CompletedProcess([], 1, '', 'HTTP 403'), + subprocess.CompletedProcess([], 0, 'not base64', ''), + subprocess.CompletedProcess([], 0, base64.b64encode(b'no macros').decode(), '')): + with self.subTest(response=response), patch.object(CONTROL.subprocess, 'run', return_value=response), \ + self.assertRaises(CONTROL.ReleaseError): + CONTROL.snapshot_version(sha) + (self.root / 'upstream-base.txt').write_text(sha + '\n') + with patch.object(CONTROL, 'snapshot_version', return_value=(26, 11, 0)): + with self.assertRaisesRegex(CONTROL.ReleaseError, 'currently pins 26.11.0'): + self.plan('26.10.0-0', snapshot=sha) + self.arm() + with self.assertRaisesRegex(CONTROL.ReleaseError, 'upstream 26.11.0'): + self.check(snapshot=sha) + + def test_snapshot_input_cannot_change_normal_tag_releases_or_builds(self): + with self.assertRaises(CONTROL.ReleaseError): + self.plan(snapshot='4' * 40) + with self.assertRaises(CONTROL.ReleaseError): + self.check(operation='build', version='', snapshot='4' * 40) + self.arm() + with self.assertRaises(CONTROL.ReleaseError): + self.check(snapshot='4' * 40) + for event in ('push', 'pull_request'): + self.assertEqual(self.check(event=event, snapshot='4' * 40)['release'], 'false') + + def test_official_release_preparation_clears_previous_snapshot_marker(self): + (self.root / 'release-upstream-snapshot.txt').write_text('4' * 40 + '\n') + CONTROL.prepare(self.root, self.plan()) + self.assertEqual((self.root / 'release-upstream-snapshot.txt').read_text(), '') + def test_wrong_version_or_line_is_rejected(self): for line, version in [('recipe', '26.10.0'), ('recipe-v26', '26.1.0'), ('dev', '26.10.0'), ('recipe-v26', '24.21.0-0')]: @@ -104,6 +178,7 @@ def test_preparation_preserves_existing_notes_without_duplicate_anchors(self): self.assertIn('Temporal and FFI.', notes) self.assertNotIn('TODO', notes) self.assertNotIn('does not arm publication', notes) + self.assertIn('\n- Preserve the next release-note bullet.', notes) self.assertEqual((self.root / 'release-ready.txt').read_text(), '26.10.0-0\n') def test_new_revision_has_review_stub_and_updated_header(self): @@ -159,17 +234,19 @@ def test_node24_and_node26_use_independent_lines(self): self.assertEqual(self.plan('24.21.0-1', 'recipe')['version'], '24.21.0-1') self.assertEqual(self.check(ref='refs/heads/recipe', version='24.21.0-1')['release'], 'true') - def test_snapshot_builds_work_but_release_preparation_requires_an_official_tag(self): + def test_node24_snapshot_uses_its_own_release_line(self): + sha = 'a5d0eb95dd03b90986ad1a7c0cd3f2de1a0f383c' self.fixture('24.22.0-0') - (self.root / 'upstream-base.txt').write_text('a' * 40 + '\n') - self.assertEqual(self.check(operation='build', version='', ref='refs/heads/recipe')['release'], - 'false') - self.assertEqual(self.check(event='pull_request')['release'], 'false') - with self.assertRaisesRegex(CONTROL.ReleaseError, 'unreleased upstream PR commit'): - self.plan('24.22.0-0', 'recipe') - self.arm('24.22.0-0') - with self.assertRaisesRegex(CONTROL.ReleaseError, 'unreleased upstream PR commit'): - self.check(ref='refs/heads/recipe', version='24.22.0-0') + (self.root / 'upstream-base.txt').write_text(sha + '\n') + with patch.object(CONTROL, 'snapshot_version', return_value=(24, 22, 0)): + release_plan = self.plan('24.22.0-0', 'recipe', snapshot=sha) + CONTROL.prepare(self.root, release_plan) + self.assertEqual(self.check(ref='refs/heads/recipe', version='24.22.0-0', + snapshot=sha)['release'], 'true') + with self.assertRaises(CONTROL.ReleaseError): + self.check(ref='refs/heads/recipe-v26', version='24.22.0-0', snapshot=sha) + with self.assertRaises(CONTROL.ReleaseError): + self.check(ref='refs/heads/recipe', version='24.22.0-0') def test_only_404_means_missing_ref(self): responses = [