diff --git a/.github/workflows/release-recovery.yml b/.github/workflows/release-recovery.yml index 580e7f30..dbb7fdad 100644 --- a/.github/workflows/release-recovery.yml +++ b/.github/workflows/release-recovery.yml @@ -31,6 +31,8 @@ jobs: with: node-version: 24 registry-url: https://registry.npmjs.org + - name: Validate release recovery tooling + run: node --test test/release-tools.test.mjs test/release-test-report.test.mjs test/stress.test.mjs - name: Validate release environment and npm authentication env: NPM_ACCESS_TOKEN: ${{ secrets.NPM_ACCESS_TOKEN }} @@ -48,16 +50,24 @@ jobs: set -euo pipefail state="$(gh run view "$SOURCE_RUN_ID" --repo "$GITHUB_REPOSITORY" --json jobs --jq ' [.jobs[] | select(.name == "failfast" or .name == "coverage" or .name == "sdk-package" or .name == "stress" - or (.name | startswith("integration (")) or (.name | startswith("package ("))) + or (.name | startswith("integration (")) or (.name | startswith("package (")) or (.name | startswith("install-lifecycle (")))] | group_by(.name) | map(max_by(.databaseId)) | if length == 14 and all(.conclusion == "success") then "success" else "failed" end')" test "$state" = success - name: Check out the immutable release source env: + GH_TOKEN: ${{ github.token }} + SOURCE_RUN_ID: ${{ inputs.source_run_id }} RELEASE_TAG: ${{ inputs.tag }} run: | set -euo pipefail + [[ "$RELEASE_TAG" =~ ^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$ ]] + test "$(git cat-file -t "refs/tags/$RELEASE_TAG")" = tag + source_run="$(gh api "repos/$GITHUB_REPOSITORY/actions/runs/$SOURCE_RUN_ID")" + test "$(jq -r .path <<< "$source_run")" = .github/workflows/release.yml + test "$(jq -r .status <<< "$source_run")" = completed + test "$(jq -r .head_sha <<< "$source_run")" = "$(git rev-parse "refs/tags/$RELEASE_TAG^{}")" git checkout --detach "refs/tags/$RELEASE_TAG" - uses: actions/download-artifact@v8 with: @@ -78,6 +88,27 @@ jobs: path: .temp/release-evidence run-id: ${{ inputs.source_run_id }} github-token: ${{ github.token }} + - name: Rebuild the top-level stress suite inventory + run: | + set -euo pipefail + python3 - <<'PY' + import json + from pathlib import Path + path=Path('.temp/release-evidence/stress-qualification/qualification.json') + report=json.loads(path.read_text()) + profiles={'smoke','p0-2c4g','scenario','p1-2c4g-peak','p1-2c4g-soak'} + runs=report['runs'] + for run in runs: + assert run['profile'] in profiles | {'reconcile'} and run['verdict']=='pass' + assert not run['global_anomalies'] + if run['profile']=='reconcile': + for item in [*run['stacks'].values(),*run['scenario'].values()]: + assert item['verdict']=='pass' and not item['anomalies'] + stages=[run for run in runs if run['profile'] in profiles] + assert len(stages)==len(profiles) and {run['profile'] for run in stages}==profiles + report['runs']=stages + path.write_text(json.dumps(report,indent=2)+'\n') + PY - name: Assemble verified release assets env: RELEASE_TAG: ${{ inputs.tag }} diff --git a/docs/releases/README.md b/docs/releases/README.md index 00937d72..14a9dbd8 100644 --- a/docs/releases/README.md +++ b/docs/releases/README.md @@ -6,7 +6,7 @@ GitHub Releases 是发布状态和二进制的权威来源。本目录保存作 | Version | Published | Notes | GitHub | | ------- | ---------- | -------------------------- | ------------------------------------------------------------------------- | -| 0.3.0 | Pending | [Release notes](0.3.0.md) | [v0.3.0](https://github.com/elliothux/open-compute/releases/tag/v0.3.0) | +| 0.3.0 | 2026-10-10 | [Release notes](0.3.0.md) | [v0.3.0](https://github.com/elliothux/open-compute/releases/tag/v0.3.0) | | 0.2.4 | 2026-10-05 | [Release notes](0.2.4.md) | [v0.2.4](https://github.com/elliothux/open-compute/releases/tag/v0.2.4) | | 0.2.3 | 2026-10-01 | [Release notes](0.2.3.md) | [v0.2.3](https://github.com/elliothux/open-compute/releases/tag/v0.2.3) | | 0.2.2 | 2026-09-25 | [Release notes](0.2.2.md) | [v0.2.2](https://github.com/elliothux/open-compute/releases/tag/v0.2.2) | diff --git a/test/conformance/baseline.json b/test/conformance/baseline.json index a77740aa..20d2bf7a 100644 --- a/test/conformance/baseline.json +++ b/test/conformance/baseline.json @@ -1,6 +1,6 @@ { "schemaVersion": 1, - "sourceDigest": "bc7d576b5f1dbcdabefa0dd1ee912e91740b4d800d281a56bdf3f9924cbca418", + "sourceDigest": "b99adb70658a312c6b4fa85e64f42aac23d82c2fcc706de29098c182ac893b0a", "sourceDigestScope": "git-visible production, test, toolchain, manifest, and maintained reference inputs; excludes generated Python caches, test/conformance/baseline.json, and non-reference docs", "workerdLockSha256": "d685331dc96e294b10a1b2365065ab6934d920c037d071b020552c7fc4f0d37f", "workerd": { diff --git a/test/release-tools.test.mjs b/test/release-tools.test.mjs index ae8f9f39..f5781f37 100644 --- a/test/release-tools.test.mjs +++ b/test/release-tools.test.mjs @@ -437,6 +437,113 @@ test("release qualification and local Docker diagnostic keep their exact boundar assert.doesNotMatch(workflow, /tolerate-republish/); }); +test("recovery rebuilds the stress index without accepting failed or unknown results", async () => { + const workflow = await readFile(recoveryWorkflowPath, "utf8"); + const qualification = workflow.match(/--json jobs --jq '([\s\S]*?)'/)?.[1]; + assert.ok(qualification); + const names = [ + "failfast", + "coverage", + "sdk-package", + "stress", + "integration (macos)", + "integration (linux)", + "package (macos)", + "package (arm64)", + "package (x64)", + ...Array.from({ length: 5 }, (_, index) => `install-lifecycle (${index})`), + ]; + const jobs = names.map((name, databaseId) => ({ + name, + databaseId, + conclusion: "success", + })); + for (const [items, expected] of [ + [jobs, "success"], + [jobs.slice(1), "failed"], + [[{ ...jobs[0], conclusion: "failure" }, ...jobs.slice(1)], "failed"], + [ + [...jobs, { ...jobs[0], databaseId: 100, conclusion: "failure" }], + "failed", + ], + ]) { + const { stdout } = await execFileAsync("jq", [ + "-nr", + "--argjson", + "evidence", + JSON.stringify({ jobs: items }), + `$evidence | ${qualification}`, + ]); + assert.equal(stdout.trim(), expected); + } + assert.match( + workflow, + /\.head_sha[\s\S]*?git rev-parse "refs\/tags\/\$RELEASE_TAG\^\{\}"/, + ); + const script = workflow + .match(/ python3 - <<'PY'\n([\s\S]*?) PY/)?.[1] + .replace(/^ /gm, ""); + assert.ok(script); + const directory = await mkdtemp(join(tmpdir(), "oc-recovery-")); + try { + const evidence = join( + directory, + ".temp/release-evidence/stress-qualification", + ); + await mkdir(evidence, { recursive: true }); + const path = join(evidence, "qualification.json"); + const profiles = [ + "smoke", + "p0-2c4g", + "scenario", + "p1-2c4g-peak", + "p1-2c4g-soak", + ]; + const events = [{ event: "restart_reconcile_ok" }]; + const runs = profiles.map((profile) => ({ + profile, + verdict: "pass", + global_anomalies: [], + ...(profile.endsWith("soak") ? { soak: { events } } : {}), + })); + const reconcile = { + profile: "reconcile", + verdict: "pass", + global_anomalies: [], + stacks: { kv: { verdict: "pass", anomalies: [] } }, + scenario: {}, + }; + const original = { + revision: "fixture", + runs: [...runs, reconcile, reconcile, reconcile], + }; + await writeFile(path, JSON.stringify(original)); + await execFileAsync("python3", ["-c", script], { cwd: directory }); + assert.deepEqual(JSON.parse(await readFile(path, "utf8")), { + revision: "fixture", + runs, + }); + for (const invalid of [ + { ...reconcile, verdict: "fail" }, + { ...reconcile, profile: "unknown" }, + { ...reconcile, global_anomalies: ["failure"] }, + { ...reconcile, stacks: { kv: { verdict: "fail", anomalies: [] } } }, + runs[0], + ]) { + await writeFile(path, JSON.stringify({ runs: [...runs, invalid] })); + await assert.rejects( + execFileAsync("python3", ["-c", script], { cwd: directory }), + ); + } + await writeFile(path, JSON.stringify({ runs: runs.slice(1) })); + await assert.rejects( + execFileAsync("python3", ["-c", script], { cwd: directory }), + ); + } finally { + await rm(directory, { recursive: true }); + } +}); + test("release assembly requires and describes the exact three native executables", async () => { const root = await mkdtemp(join(tmpdir(), "oc-release-assembly-test-")); const evidenceDirectory = await mkdtemp( diff --git a/test/stress.test.mjs b/test/stress.test.mjs index 20bec332..ec9dd0bf 100644 --- a/test/stress.test.mjs +++ b/test/stress.test.mjs @@ -200,6 +200,80 @@ done await rm(directory, { recursive: true }); }); +test("release qualification selects five stages and retains soak recovery evidence", async () => { + const directory = await fixture(); + try { + const source = await readFile( + join(root, "test/stress/qualify-release.sh"), + "utf8", + ); + const collector = source.match( + /python3 - "\$STRESS_RUN_ROOT" "\$package_report" <<'PY'\n([\s\S]*?)\nPY/, + )?.[1]; + assert.ok(collector); + const packagePath = join(directory, "package.json"); + await writeFile( + packagePath, + JSON.stringify({ + version: "1.2.3", + revision: "fixture", + sha256: "fixture", + workerdLockSha256: "fixture", + }), + ); + const profiles = [ + "smoke", + "p0-2c4g", + "scenario", + "p1-2c4g-peak", + "p1-2c4g-soak", + ]; + const events = [{ event: "restart_reconcile_ok" }]; + for (const [index, profile] of [ + ...profiles, + "reconcile", + "reconcile", + "reconcile", + ].entries()) { + await mkdir(join(directory, String(index))); + await writeFile( + join(directory, String(index), "result.json"), + JSON.stringify({ + profile, + verdict: "pass", + ...(profile.endsWith("soak") ? { soak: { events } } : {}), + }), + ); + } + const collect = () => + exec("python3", ["-c", collector, directory, packagePath]); + await collect(); + const qualification = JSON.parse( + await readFile(join(directory, "qualification.json"), "utf8"), + ); + assert.deepEqual( + qualification.runs.map((run) => run.profile), + profiles, + ); + assert.deepEqual(qualification.runs.at(-1).soak.events, events); + const first = join(directory, "0/result.json"); + await writeFile( + first, + JSON.stringify({ profile: "smoke", verdict: "fail" }), + ); + await assert.rejects(collect()); + await writeFile( + first, + JSON.stringify({ profile: "scenario", verdict: "pass" }), + ); + await assert.rejects(collect()); + await rm(first); + await assert.rejects(collect()); + } finally { + await rm(directory, { recursive: true }); + } +}); + test("final qualification exits unsuccessfully and preserves failed evidence", async () => { const directory = await fixture(); const result = join(directory, "result.json"); diff --git a/test/stress/qualify-release.sh b/test/stress/qualify-release.sh index 057cced1..134b6021 100755 --- a/test/stress/qualify-release.sh +++ b/test/stress/qualify-release.sh @@ -85,9 +85,15 @@ python3 - "$STRESS_RUN_ROOT" "$package_report" <<'PY' import json,sys from pathlib import Path root,package=Path(sys.argv[1]),json.loads(Path(sys.argv[2]).read_text()) +profiles=['smoke','p0-2c4g','scenario','p1-2c4g-peak','p1-2c4g-soak'] +results=[json.loads(p.read_text()) for p in sorted(root.glob('*/result.json'))] +# Reconcile results are internal soak evidence; verified recovery stays in soak.events. +runs=[r for r in results if r.get('profile') in profiles] +assert len(runs)==len(profiles) and {r['profile'] for r in runs}==set(profiles) +assert all(r['verdict']=='pass' for r in runs) report={'schemaVersion':1,'status':'passed','version':package['version'],'revision':package['revision'], 'candidateSha256':package['sha256'],'workerdLockSha256':package['workerdLockSha256'], - 'cpuLimit':2,'memoryLimitBytes':4*1024**3,'runs':[json.loads(p.read_text()) for p in sorted(root.glob('*/result.json'))]} + 'cpuLimit':2,'memoryLimitBytes':4*1024**3,'runs':runs} (root/'qualification.json').write_text(json.dumps(report,indent=2)+'\n') PY printf 'release stress qualification passed: %s\n' "$STRESS_RUN_ROOT/qualification.json"