From 7a973d42f576053ea1335b78552515a23f81097d Mon Sep 17 00:00:00 2001 From: Jintao Date: Tue, 4 Aug 2026 08:29:58 +0800 Subject: [PATCH] Harden v0.2.0 source candidate readiness --- .github/workflows/ci.yml | 11 +- .github/workflows/source-release.yml | 18 ++- CHANGELOG.en.md | 6 + CHANGELOG.md | 4 + Docs/Releases/0.2.0.md | 4 + Docs/Testing.md | 13 ++- Docs/en/Releases/0.2.0.md | 6 + Docs/en/Testing.md | 17 ++- Scripts/verify-source-release.rb | 104 +++++++++++++++--- .../Scripts/SourceReleaseVerificationTests.rb | 102 ++++++++++++++++- 10 files changed, 258 insertions(+), 27 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index acf73e4..e45a570 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -103,7 +103,16 @@ jobs: ruby Scripts/verify-source-release.rb \ --version 0.2.0 \ --ref HEAD \ - --allow-source-blocked + --allow-source-blocked \ + --report "$RUNNER_TEMP/pocketroot-v0.2.0-source-candidate.json" + + - name: Upload source candidate audit report + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: pocketroot-source-candidate-${{ github.run_id }}-${{ github.run_attempt }} + path: ${{ runner.temp }}/pocketroot-v0.2.0-source-candidate.json + if-no-files-found: error + retention-days: 14 - name: Report Apple toolchain run: | diff --git a/.github/workflows/source-release.yml b/.github/workflows/source-release.yml index 3c396e6..f22879b 100644 --- a/.github/workflows/source-release.yml +++ b/.github/workflows/source-release.yml @@ -63,7 +63,8 @@ jobs: --tooling-root trusted \ --version "$version" \ --ref "$RELEASE_TAG" \ - --require-tag "$RELEASE_TAG" + --require-tag "$RELEASE_TAG" \ + --report "$RUNNER_TEMP/pocketroot-$version-source-release.json" - name: Resolve exact Swift Package version externally env: @@ -74,3 +75,18 @@ jobs: commit="$(git -C candidate rev-parse "$RELEASE_TAG^{commit}")" ./trusted/Scripts/verify-immutable-upstream-releases.sh ./trusted/Scripts/verify-swiftpm-release-tag.sh "$version" "$commit" + + - name: Require source release verification report + env: + RELEASE_TAG: ${{ inputs.tag }} + run: | + version="${RELEASE_TAG#v}" + test -f "$RUNNER_TEMP/pocketroot-$version-source-release.json" + + - name: Publish source release verification report artifact + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: pocketroot-source-release-${{ inputs.tag }}-${{ github.run_id }}-${{ github.run_attempt }} + path: ${{ runner.temp }}/pocketroot-0.2.0-source-release.json + if-no-files-found: error + retention-days: 30 diff --git a/CHANGELOG.en.md b/CHANGELOG.en.md index 5298dde..d6e8d9b 100644 --- a/CHANGELOG.en.md +++ b/CHANGELOG.en.md @@ -8,6 +8,12 @@ All notable PocketRoot changes are recorded here. Semantic Versioning begins wit ### Changed +- Tightened the untagged `v0.2.0` source-candidate audit: + `--allow-source-blocked` accepts only final source-release authorization as + unsatisfied, and fails on any pinned-gate-set, NOTICE, license, public API + status, or source-boundary drift. CI and the trusted tag workflow upload only + a JSON verification report with the commit, archive SHA-256, file counts, + and exact blockers—not the temporary source tar, RootFS, App, or binaries. - Split the minimum-Xcode 16 gate into an independent native-runtime job and a five-way `fail-fast: false` UI matrix for the public-SHA external consumer, Quick Start iPhone/iPad, and Host App iPhone/iPad. Every isolated runner diff --git a/CHANGELOG.md b/CHANGELOG.md index 61f64d0..e889b7c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,10 @@ PocketRoot 的重要变化记录在这里,并从首个公开版本开始遵循 ### Changed +- 收紧 `v0.2.0` 未打 tag 源码候选审计:`--allow-source-blocked` 只接受最终源码发布 + 授权这一项尚未满足,固定门禁集合、NOTICE、许可证、公开 API 状态或源码边界漂移 + 都会失败;CI 和受信 tag 工作流只上传带 commit、archive SHA-256、文件统计和精确 + 阻塞项的 JSON 验证报告,不上传临时源码 tar、RootFS、App 或二进制制品。 - 将最低 Xcode 16 门禁拆为独立 native runtime job 和五路 `fail-fast: false` UI matrix,分别并行验证公开 SHA 外部消费者、Quick Start iPhone/iPad 与 Host App iPhone/iPad。全部 job 复用仓库内 composite action,但各自在隔离 runner 上重新 diff --git a/Docs/Releases/0.2.0.md b/Docs/Releases/0.2.0.md index 41b6c5c..2a14512 100644 --- a/Docs/Releases/0.2.0.md +++ b/Docs/Releases/0.2.0.md @@ -53,6 +53,10 @@ archive、IPA、XCFramework 镜像或二进制 SDK,也不创建 tag 或 GitHub RootFS 继续由调用方合法取得、审核并作为本地输入提供;Runtime/App/二进制分发和 RootFS 再分发保持 fail-closed。 +未打 tag 的候选审计只容许 `source-release-authorized` 这一项尚未满足;源码边界、 +公开 API 状态、LICENSE、贡献者政策和 NOTICE 必须全部通过。CI 生成带 commit、 +archive SHA-256、文件统计和精确阻塞项的 JSON 报告,但不保留或上传源码 tar。 + ## 尚未关闭 - v0.2.0 源码发布的项目所有者授权、发布日期、annotated tag 和外部精确版本解析; diff --git a/Docs/Testing.md b/Docs/Testing.md index 431e574..7dd93be 100644 --- a/Docs/Testing.md +++ b/Docs/Testing.md @@ -19,7 +19,7 @@ PocketRoot 把验证分成宿主逻辑、真实 RootFS、iOS 构建、完整原 | Host App UI smoke | `./Scripts/run-host-app-ui-smoke.sh` | Apple Silicon + iOS 18 Simulator + archive | iPhone/iPad Simulator 上的公开宿主 Boot、SwiftTerm PTY、生命周期、Workspace 会话持续性、Files 增删改/预览、系统 document picker 导入、share sheet 保存与再次导入 round-trip,以及有序 shutdown | 真机系统文件交互、真机键盘、iPad 真机或发行可用 | | Host App 真机 UI smoke | `./Scripts/run-host-app-device-ui-smoke.sh` | Xcode 可解析的 development-signed iPhone/iPad + archive | 同一 Host App 生命周期 UI 测试的真机执行、签名与 development entitlement | iPad、真实压力或发行可用 | | 物理设备原生 smoke | `./Scripts/run-runtime-device-smoke.sh` | 签名 iOS 18+ iPhone/iPad + archive | 同一 17 项检查、可选进程暂停/恢复、UIKit 前后台、强制重启持久化、受限存储故障、有界内存警告恢复或持久 PTY 稳定性,development entitlement 与 shutdown 返回 | 真实 storage/memory pressure、断电、jetsam、iPad 或发行可用 | -| 源码发布审计 | `ruby Scripts/verify-source-release.rb --version 0.2.0 --allow-source-blocked` | Git commit | 候选版本文档齐全,`git archive` 不含 RootFS、App、IPA、XCFramework 镜像、压缩载荷或原生二进制 | 已授予源码发布授权或 Runtime/App/RootFS 分发授权 | +| 源码发布审计 | `ruby Scripts/verify-source-release.rb --version 0.2.0 --allow-source-blocked` | Git commit | 除最终源码发布授权外的门禁全部满足,候选版本文档齐全,`git archive` 不含 RootFS、App、IPA、XCFramework 镜像、压缩载荷或原生二进制 | 已授予源码发布授权或 Runtime/App/RootFS 分发授权 | | 文档检查 | `./Scripts/check-docs.sh` | macOS/Linux shell | 中英文成对、中文覆盖和相对链接 | 技术实现正确 | ## 2. 宿主 Swift Package 测试 @@ -588,14 +588,19 @@ Workspace、系统 document picker 导入、share sheet 保存、guest 删除后 失败,也保留首次 `xcodebuild` 的诊断结果。 这些 Simulator 结果不证明签名真机或发行可用。 -当前候选 PR 只允许以 `--allow-source-blocked` 审计未打 tag 的 `v0.2.0` commit; +当前候选 PR 只允许以 `--allow-source-blocked` 审计未打 tag 的 `v0.2.0` commit。 +这个模式不是绕过门禁:它要求固定的源码门禁集合和顺序完全一致,并且唯一未满足项 +必须是 `source-release-authorized`;NOTICE、许可证、公开 API 状态或源码边界出现任何 +回退都会失败。审计报告记录 schema、commit、archive SHA-256、文件统计、精确阻塞项 +和授权状态;CI 只保留该 JSON 报告 14 天,不保留或上传临时源码 tar。 `--require-source-ready` 与 tag 工作流保持失败关闭。只有明确授予源码发布授权并再次 评审后,才可推送 `v0.2.0` annotated tag,并从受保护的 `main` 手动调度 `.github/workflows/source-release.yml`。工作流使用 `main` checkout 里的可信校验工具审计独立的 tag checkout,要求 annotated tag 的 commit 位于该 `main` 历史上,重新生成并扫描 `git archive`,随后从仓库外以 `exact: "0.2.0"` -解析公开 Swift Package,并核对解析版本与 peeled commit。该工作流不会创建或上传 -RootFS、App、IPA、XCFramework 或二进制 SDK。 +解析公开 Swift Package,并核对解析版本与 peeled commit。成功后只保留 JSON 验证 +报告 30 天;该工作流不会创建或上传源码 tar、RootFS、App、IPA、XCFramework 或 +二进制 SDK。 ## 9. 改动与最小验证矩阵 diff --git a/Docs/en/Releases/0.2.0.md b/Docs/en/Releases/0.2.0.md index 4ad4e3b..dd703e0 100644 --- a/Docs/en/Releases/0.2.0.md +++ b/Docs/en/Releases/0.2.0.md @@ -64,6 +64,12 @@ or binary SDK, and it creates no tag or GitHub Release. Callers must continue to lawfully obtain and review a local RootFS input. Runtime/App/binary distribution and RootFS redistribution remain fail-closed. +The untagged candidate audit permits only `source-release-authorized` to remain +unsatisfied. Source boundaries, public API status, LICENSE, contributor policy, +and NOTICE must all pass. CI writes a JSON report containing the commit, +archive SHA-256, file counts, and exact blockers, but neither retains nor +uploads the source tar. + ## Still open - Owner authorization, release date, annotated tag, and external exact-version diff --git a/Docs/en/Testing.md b/Docs/en/Testing.md index 4db2ccd..ef9293d 100644 --- a/Docs/en/Testing.md +++ b/Docs/en/Testing.md @@ -19,7 +19,7 @@ PocketRoot separates host logic, real RootFS, iOS build, native final-link, and | Host App UI smoke | `./Scripts/run-host-app-ui-smoke.sh` | Public-host boot, SwiftTerm PTY, lifecycle, Workspace persistence, Files mutations/previews, system document-picker import, share-sheet save and re-import round trip, and ordered shutdown on iPhone/iPad iOS 18 Simulators | Physical-device system file interaction, physical keyboards, physical iPad, distribution | | Physical Host App UI smoke | `./Scripts/run-host-app-device-ui-smoke.sh` | The same lifecycle UI test on an Xcode-resolved, development-signed iPhone/iPad, including signature and development entitlements | iPad, real pressure, distribution | | Physical native smoke | `./Scripts/run-runtime-device-smoke.sh` | Same 17 checks with optional process suspend/resume, UIKit lifecycle, forced-relaunch persistence, bounded storage-failure recovery, bounded memory-warning recovery, or persistent-PTY stability; development entitlements and returning soft shutdown | Real storage/memory pressure, power cut, jetsam, iPad, distribution | -| Source-release audit | `ruby Scripts/verify-source-release.rb --version 0.2.0 --allow-source-blocked` | Complete candidate documents and a `git archive` without RootFS, App, IPA, XCFramework mirror, compressed payload, or native binary content | Granted source-release authorization or Runtime/App/RootFS distribution authorization | +| Source-release audit | `ruby Scripts/verify-source-release.rb --version 0.2.0 --allow-source-blocked` | Every gate except final source-release authorization is satisfied, candidate documents are complete, and `git archive` contains no RootFS, App, IPA, XCFramework mirror, compressed payload, or native binary content | Granted source-release authorization or Runtime/App/RootFS distribution authorization | | Documentation | `./Scripts/check-docs.sh` | Pairs, Chinese coverage, relative links | Implementation correctness | ## Package tests @@ -539,15 +539,22 @@ final logs plus both available `.xcresult` bundles. This Simulator evidence does not prove signed-device or distribution readiness. This candidate PR audits only an untagged `v0.2.0` commit with -`--allow-source-blocked`; `--require-source-ready` and the tag workflow remain -fail closed. Only after explicit source-release authorization and another -review may an annotated `v0.2.0` tag be pushed and +`--allow-source-blocked`. That mode does not bypass gates: it requires the +exact pinned source-gate set and order, with `source-release-authorized` as the +only unsatisfied gate. Any NOTICE, license, public-API-status, or source-boundary +regression fails the audit. The JSON report records its schema, commit, archive +SHA-256, file counts, exact blockers, and authorization status. CI retains only +that JSON report for 14 days and neither retains nor uploads the temporary +source tar. `--require-source-ready` and the tag workflow remain fail closed. +Only after explicit source-release authorization and another review may an +annotated `v0.2.0` tag be pushed and `.github/workflows/source-release.yml` dispatched from protected `main`. The workflow uses trusted verifier tooling from the `main` checkout against a separate tag checkout, requires the annotated tag commit to belong to that trusted `main` history, regenerates and audits the `git archive`, then resolves the public package externally with `exact: "0.2.0"` and verifies both the resolved version -and peeled commit. It does not create or upload a RootFS, App, IPA, +and peeled commit. On success it retains only the JSON verification report for +30 days. It does not create or upload a source tar, RootFS, App, IPA, XCFramework, or binary SDK. ## Minimum checks by change diff --git a/Scripts/verify-source-release.rb b/Scripts/verify-source-release.rb index f6685c1..37d4154 100755 --- a/Scripts/verify-source-release.rb +++ b/Scripts/verify-source-release.rb @@ -14,6 +14,15 @@ module PocketRootSourceRelease class VerificationError < StandardError; end RELEASE_VERSION = "0.2.0" + SOURCE_AUTHORIZATION_GATE_ID = "source-release-authorized" + EXPECTED_SOURCE_GATE_IDS = %w[ + source-boundary-excludes-rootfs + public-api-status-declared + top-level-license-finalized + contributor-policy-approved + release-notice-approved + source-release-authorized + ].freeze MAX_ENTRIES = 20_000 MAX_FILE_BYTES = 64 * 1024 * 1024 MAX_ARCHIVE_CONTENT_BYTES = 256 * 1024 * 1024 @@ -175,25 +184,75 @@ def verify_source_readiness( decisions_path = root.join("Compliance/Release/RELEASE-DECISIONS.json") readiness = load_json(readiness_path, "release readiness") decisions = load_json(decisions_path, "release decisions") - source_status = readiness.dig("tracks", "sourcePackageRelease", "status") + source_track = readiness.dig("tracks", "sourcePackageRelease") + source_status = source_track&.fetch("status", nil) unless readiness["releaseVersion"] == version && decisions["releaseVersion"] == version && %w[blocked ready].include?(source_status) raise VerificationError, "source readiness is not bound to release #{version}" end - if require_ready && - ( - source_status != "ready" || - !%w[ - source-release-authorized - source-and-runtime-distribution-authorized - ].include?(decisions["status"]) - ) + gates = source_track.fetch("gates") + unless gates.is_a?(Array) && + gates.all? { |gate| gate.is_a?(Hash) } && + gates.map { |gate| gate["id"] } == EXPECTED_SOURCE_GATE_IDS + raise VerificationError, + "source readiness gate set is not bound to release #{version}" + end + blocked_gate_ids = gates.each_with_object([]) do |gate, blocked| + blocked << gate.fetch("id") unless gate.fetch("satisfied") == true + end + authorized = %w[ + source-release-authorized + source-and-runtime-distribution-authorized + ].include?(decisions["status"]) + if require_ready && (source_status != "ready" || !authorized) raise VerificationError, "source authorization is not bound to release #{version}" end - source_status + if source_status == "ready" && (!blocked_gate_ids.empty? || !authorized) + raise VerificationError, + "ready source track has unresolved gates for release #{version}" + end + if !require_ready && source_status == "blocked" && + blocked_gate_ids != [SOURCE_AUTHORIZATION_GATE_ID] + raise VerificationError, + "source candidate has unresolved gates beyond explicit release " \ + "authorization: #{blocked_gate_ids.join(', ')}" + end + + { + "status" => source_status, + "candidateReady" => blocked_gate_ids.empty? || + blocked_gate_ids == [SOURCE_AUTHORIZATION_GATE_ID], + "blockedGateIds" => blocked_gate_ids, + "authorizationStatus" => decisions.fetch("status") + } + end + + def write_report(path, result) + report = Pathname(path).expand_path + report.dirname.mkpath + if report.symlink? || (report.exist? && !report.file?) + raise VerificationError, + "source verification report path is not a regular file: #{report}" + end + + temporary = Tempfile.new( + [".#{report.basename}", ".tmp"], + report.dirname.to_s + ) + temporary.binmode + temporary.write(JSON.pretty_generate(result)) + temporary.write("\n") + temporary.flush + temporary.fsync + temporary.close + File.rename(temporary.path, report) + report + ensure + temporary&.close unless temporary&.closed? + temporary&.unlink end def verify_annotated_tag(root, ref, tag) @@ -461,7 +520,7 @@ def verify( prefix, required_paths: REQUIRED_PATHS + release_paths ) - source_status = nil + source_readiness = nil Dir.mktmpdir("pocketroot-source-release-snapshot-") do |directory| release_root = materialize_archive( archive, @@ -473,18 +532,24 @@ def verify( version, require_released: require_source_ready ) - source_status = verify_source_readiness( + source_readiness = verify_source_readiness( release_root, version, tooling_root: Pathname(tooling_root).realpath, require_ready: require_source_ready ) end - result = result.merge( + result = { + "schemaVersion" => 1 + }.merge(result).merge( "releaseVersion" => version, "ref" => ref, "commit" => run_git(root, "rev-parse", "#{ref}^{commit}"), - "sourceTrack" => source_status + "sourceTrack" => source_readiness.fetch("status"), + "sourceCandidateReady" => source_readiness.fetch("candidateReady"), + "sourceBlockedGateIds" => source_readiness.fetch("blockedGateIds"), + "authorizationStatus" => + source_readiness.fetch("authorizationStatus") ) result["archivePath"] = archive.to_s if output result @@ -526,6 +591,9 @@ def verify( cli.on("--output PATH", "Keep the verified tar at PATH") do |value| options[:output] = value end + cli.on("--report PATH", "Write the JSON verification report at PATH") do |value| + options[:report] = value + end end parser.parse! raise OptionParser::MissingArgument, "--version" unless options[:version] @@ -539,6 +607,14 @@ def verify( tooling_root: options.fetch(:tooling_root, options[:root]), require_source_ready: options[:require_source_ready] ) + if options[:report] + report = Pathname(options[:report]).expand_path + if options[:output] && report == Pathname(options[:output]).expand_path + raise PocketRootSourceRelease::VerificationError, + "source archive and JSON report paths must be different" + end + PocketRootSourceRelease.write_report(report, result) + end puts JSON.pretty_generate(result) rescue OptionParser::ParseError, PocketRootSourceRelease::VerificationError => error diff --git a/Tests/Scripts/SourceReleaseVerificationTests.rb b/Tests/Scripts/SourceReleaseVerificationTests.rb index bc1c896..2de0107 100755 --- a/Tests/Scripts/SourceReleaseVerificationTests.rb +++ b/Tests/Scripts/SourceReleaseVerificationTests.rb @@ -29,12 +29,97 @@ def test_current_release_candidate_passes_source_archive_audit_while_blocked ) assert_equal "blocked", result.fetch("sourceTrack") + assert result.fetch("sourceCandidateReady") + assert_equal( + ["source-release-authorized"], + result.fetch("sourceBlockedGateIds") + ) + assert_equal( + "no-release-authorization-granted", + result.fetch("authorizationStatus") + ) + assert_equal 1, result.fetch("schemaVersion") refute result.fetch("rootFSIncluded") refute result.fetch("runtimeArtifactIncluded") assert_match(/\A[0-9a-f]{64}\z/, result.fetch("archiveSha256")) assert_operator result.fetch("regularFileCount"), :>, 100 end + def test_blocked_candidate_rejects_any_non_authorization_source_gate + repository = release_repository( + version: "0.2.0", + authorized_version: "0.2.0", + authorization_status: "no-release-authorization-granted", + source_status: "blocked", + blocked_gate_ids: [ + "release-notice-approved", + "source-release-authorized" + ] + ) + + error = assert_raises(PocketRootSourceRelease::VerificationError) do + PocketRootSourceRelease.verify( + root: repository, + ref: "HEAD", + version: "0.2.0", + require_source_ready: false + ) + end + + assert_includes error.message, + "unresolved gates beyond explicit release authorization" + assert_includes error.message, "release-notice-approved" + end + + def test_writes_atomic_json_verification_report + report = @temporary_directory.join("reports/source-candidate.json") + result = { + "schemaVersion" => 1, + "sourceCandidateReady" => true, + "sourceBlockedGateIds" => ["source-release-authorized"] + } + + written = PocketRootSourceRelease.write_report(report, result) + + assert_equal report, written + assert_equal result, JSON.parse(report.binread) + assert report.binread.end_with?("\n") + end + + def test_rejects_symbolic_link_report_destination + target = @temporary_directory.join("target.json") + target.binwrite("preserve\n") + report = @temporary_directory.join("report.json") + File.symlink(target, report) + + error = assert_raises(PocketRootSourceRelease::VerificationError) do + PocketRootSourceRelease.write_report(report, {"verified" => true}) + end + + assert_includes error.message, "not a regular file" + assert_equal "preserve\n", target.binread + end + + def test_ci_and_tag_workflows_publish_json_verification_reports_only + ci = REPOSITORY_ROOT.join(".github/workflows/ci.yml").binread + release = + REPOSITORY_ROOT.join(".github/workflows/source-release.yml").binread + + assert_includes ci, + '--report "$RUNNER_TEMP/pocketroot-v0.2.0-source-candidate.json"' + assert_includes ci, "Upload source candidate audit report" + assert_includes ci, + "path: ${{ runner.temp }}/pocketroot-v0.2.0-source-candidate.json" + assert_includes release, + '--report "$RUNNER_TEMP/pocketroot-$version-source-release.json"' + assert_includes release, + "Publish source release verification report artifact" + assert_includes release, + "path: ${{ runner.temp }}/pocketroot-0.2.0-source-release.json" + refute_match(/path:.*\.tar\s*$/i, ci) + refute_match(/path:.*\.tar\s*$/i, release) + end + def test_current_release_candidate_fails_closed_without_source_authorization error = assert_raises(PocketRootSourceRelease::VerificationError) do PocketRootSourceRelease.verify( @@ -282,7 +367,9 @@ def test_rejects_non_annotated_release_tag def release_repository( version:, authorized_version:, - authorization_status: "source-release-authorized" + authorization_status: "source-release-authorized", + source_status: "ready", + blocked_gate_ids: [] ) repository = @temporary_directory.join("release-repository-#{version}") repository.mkpath @@ -307,7 +394,18 @@ def release_repository( compliance_script.binwrite("exit(%w[--check --require-source-ready].include?(ARGV.first) ? 0 : 2)\n") readiness = { "releaseVersion" => authorized_version, - "tracks" => {"sourcePackageRelease" => {"status" => "ready"}} + "tracks" => { + "sourcePackageRelease" => { + "status" => source_status, + "gates" => + PocketRootSourceRelease::EXPECTED_SOURCE_GATE_IDS.map do |id| + { + "id" => id, + "satisfied" => !blocked_gate_ids.include?(id) + } + end + } + } } readiness_path = repository.join( "Compliance/Release/experimental-v#{version}/READINESS.json"