diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 43490104a..339047749 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -35,6 +35,10 @@ jobs: # release gates (`pnpm check:release`) so the tree stays publishable. PUBLISH_ENABLED: ${{ vars.AGENT_BUNDLE_NPM_PUBLISH == 'true' && secrets.NPM_TOKEN != '' }} AGENT_BUNDLE_PLAYWRIGHT_CHANNEL: chromium + # Packed qualification writes package/digest evidence here when + # `pnpm check:release` / `pnpm release` runs. `github.workspace` is + # valid at job env; `runner.temp` is not (no runner assigned yet). + AGENT_BUNDLE_RELEASE_EVIDENCE: ${{ github.workspace }}/.release-qualification.json steps: # The action pushes and opens the PR through the GitHub API with the # token passed below, so the checkout must not persist GITHUB_TOKEN. @@ -78,8 +82,43 @@ jobs: # changesets, "Version Packages" merge commit), prove the versioned # tree still passes the pre-publish gates that `pnpm release` would run. - name: Release gates (publish disabled) + id: qualify if: >- env.PUBLISH_ENABLED != 'true' && steps.changesets.outputs.has-changesets == 'false' && startsWith(github.event.head_commit.message, 'Version Packages') run: pnpm check:release + # Registry proof is a separate authorized-publication step. Disabled + # publication is a supported outcome and must not call npm view. + - name: Verify published registry artifacts + id: registry + if: env.PUBLISH_ENABLED == 'true' && steps.changesets.outputs.published == 'true' + env: + NPM_TOKEN: ${{ secrets.NPM_TOKEN }} + run: | + set -euo pipefail + for dir in \ + packages/agent-bundle \ + packages/rsc-runtime \ + packages/rsc-markdown-stream \ + packages/create-agent-bundle + do + name=$(node -p "JSON.parse(require('node:fs').readFileSync('$dir/package.json','utf8')).name") + version=$(node -p "JSON.parse(require('node:fs').readFileSync('$dir/package.json','utf8')).version") + published=$(npm view "$name@$version" version) + test "$published" = "$version" + echo "registry $name@$version" + done + - name: Release outcome summary + if: always() + env: + HAS_CHANGESETS: ${{ steps.changesets.outputs.has-changesets }} + PUBLISHED: ${{ steps.changesets.outputs.published }} + QUALIFY_OUTCOME: ${{ steps.qualify.outcome }} + CHANGESETS_OUTCOME: ${{ steps.changesets.outcome }} + REGISTRY_OUTCOME: ${{ steps.registry.outcome }} + JOB_STATUS: ${{ job.status }} + CANDIDATE_SHA: ${{ github.sha }} + EVIDENCE_FILE: ${{ env.AGENT_BUNDLE_RELEASE_EVIDENCE }} + GH_TOKEN: ${{ github.token }} + run: bash scripts/release-outcome-summary.sh >> "$GITHUB_STEP_SUMMARY" diff --git a/docs/preview-packages.md b/docs/preview-packages.md index a296303b2..916977d1a 100644 --- a/docs/preview-packages.md +++ b/docs/preview-packages.md @@ -95,11 +95,16 @@ changes a publishable package carries a `.changeset/*.md`; on each push to a machine-owned **Version Packages** pull request up to date with the pending bumps and `CHANGELOG.md` entries. Merging that PR versions the packages but, by default, publishes nothing: the workflow only runs the release gates -(`pnpm check:release`). Publishing turns on when the repository variable -`AGENT_BUNDLE_NPM_PUBLISH` is `true` *and* the `NPM_TOKEN` secret exists; -the action then runs `pnpm release` (`pnpm check:release && changeset -publish`) with npm provenance. Until then, previews below are the only -installable artifacts. +(`pnpm check:release`) against that exact versioned candidate SHA and +records `qualified-without-publish`. A push that only refreshes the Version +Packages PR — or that neither refreshes it nor qualifies a versioned +candidate — records `version-maintenance-only`. Publishing turns on when the +repository variable `AGENT_BUNDLE_NPM_PUBLISH` is `true` *and* the +`NPM_TOKEN` secret exists; the action then runs `pnpm release` +(`pnpm check:release && changeset publish`) with npm provenance and records +`published` after registry verification. Disabled publication reports +**NOT PUBLISHED**. Until then, previews below are the only installable +artifacts. ## Where previews come from diff --git a/packages/agent-bundle/tests/release-outcome-summary.test.ts b/packages/agent-bundle/tests/release-outcome-summary.test.ts new file mode 100644 index 000000000..e104bcdd4 --- /dev/null +++ b/packages/agent-bundle/tests/release-outcome-summary.test.ts @@ -0,0 +1,125 @@ +import { execFile as executeFile } from 'node:child_process'; +import { mkdtemp, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { promisify } from 'node:util'; + +import { expect, it } from '@rstest/core'; + +const execFile = promisify(executeFile); +const scriptPath = join(dirname(fileURLToPath(import.meta.url)), '../../../scripts/release-outcome-summary.sh'); +const candidateSha = 'b435f7b9179271cbff81d3e40d14ee342cbd65dd'; + +const summarize = async (extra: NodeJS.ProcessEnv) => { + const { stdout } = await execFile('bash', [scriptPath], { + env: { PATH: process.env['PATH'], CANDIDATE_SHA: candidateSha, ...extra }, + }); + return stdout; +}; + +it('reports version-maintenance-only as NOT PUBLISHED with skipped qualification stages', async () => { + expect(await summarize({ + PUBLISH_ENABLED: 'false', + HAS_CHANGESETS: 'true', + PUBLISHED: 'false', + QUALIFY_OUTCOME: 'skipped', + CHANGESETS_OUTCOME: 'success', + REGISTRY_OUTCOME: 'skipped', + JOB_STATUS: 'success', + })).toContain([ + 'outcome: version-maintenance-only', + `workflow_sha: ${candidateSha}`, + 'candidate_sha: (not qualified)', + 'publication: NOT PUBLISHED', + '', + 'stages:', + '- version-maintenance: executed', + '- qualification: skipped', + '- publication: skipped', + '- registry-verification: skipped', + ].join('\n')); +}); + +it('reports qualified-without-publish with pack evidence and pending follow-ups', async () => { + const evidenceFile = join(await mkdtemp(join(tmpdir(), 'release-outcome-')), 'evidence.json'); + await writeFile(evidenceFile, `${JSON.stringify({ + executedBins: ['agent-bundle'], + interPackageRanges: [{ + field: 'dependencies', + name: 'rsc-markdown-stream', + package: '@agent-bundle/runtime', + specifier: '^0.1.0', + }], + packages: [{ + digest: 'deadbeef', + name: 'agent-bundle', + tarball: 'agent-bundle-0.1.0.tgz', + version: '0.1.0', + }], + testGroups: ['packed', 'packed-release'], + workspaceRefs: [], + })}\n`); + const stdout = await summarize({ + PUBLISH_ENABLED: 'false', + HAS_CHANGESETS: 'false', + PUBLISHED: 'false', + QUALIFY_OUTCOME: 'success', + CHANGESETS_OUTCOME: 'success', + REGISTRY_OUTCOME: 'skipped', + JOB_STATUS: 'success', + EVIDENCE_FILE: evidenceFile, + }); + expect(stdout).toContain([ + 'outcome: qualified-without-publish', + `workflow_sha: ${candidateSha}`, + `candidate_sha: ${candidateSha}`, + 'publication: NOT PUBLISHED', + '', + 'stages:', + '- version-maintenance: skipped', + '- qualification: executed', + '- publication: skipped', + '- registry-verification: skipped', + ].join('\n')); + expect(stdout).toContain('- agent-bundle@0.1.0 agent-bundle-0.1.0.tgz sha256:deadbeef'); + expect(stdout).toContain('- @agent-bundle/runtime dependencies rsc-markdown-stream: ^0.1.0'); + expect(stdout).toContain('workspace-only refs:\n- none'); + expect(stdout).toContain('- packed-release: executed'); + expect(stdout).toContain('- #688 schema-label provenance: pending'); +}); + +it('reports cancelled qualification as failed', async () => { + expect(await summarize({ + PUBLISH_ENABLED: 'false', + HAS_CHANGESETS: 'false', + PUBLISHED: 'false', + QUALIFY_OUTCOME: 'cancelled', + CHANGESETS_OUTCOME: 'success', + REGISTRY_OUTCOME: 'skipped', + JOB_STATUS: 'cancelled', + })).toContain('outcome: failed\n'); +}); + +it('reports published only when publish is enabled and registry succeeded', async () => { + expect(await summarize({ + PUBLISH_ENABLED: 'true', + HAS_CHANGESETS: 'false', + PUBLISHED: 'true', + QUALIFY_OUTCOME: 'skipped', + CHANGESETS_OUTCOME: 'success', + REGISTRY_OUTCOME: 'success', + JOB_STATUS: 'success', + })).toContain([ + 'outcome: published', + `workflow_sha: ${candidateSha}`, + `candidate_sha: ${candidateSha}`, + 'publication: published', + '', + 'stages:', + '- version-maintenance: skipped', + '- qualification: executed', + '- publication: executed', + '- registry-verification: executed', + ].join('\n')); +}); diff --git a/scripts/release-outcome-summary.sh b/scripts/release-outcome-summary.sh new file mode 100644 index 000000000..7e27fc221 --- /dev/null +++ b/scripts/release-outcome-summary.sh @@ -0,0 +1,132 @@ +#!/usr/bin/env bash +# Release packages workflow summary: outcome, stages, and optional pack evidence. +set -euo pipefail + +publish_enabled=${PUBLISH_ENABLED:-false} +has_changesets=${HAS_CHANGESETS:-false} +published=${PUBLISHED:-false} +qualify_outcome=${QUALIFY_OUTCOME:-skipped} +changesets_outcome=${CHANGESETS_OUTCOME:-skipped} +registry_outcome=${REGISTRY_OUTCOME:-skipped} +job_status=${JOB_STATUS:-success} +workflow_sha=${CANDIDATE_SHA:-} + +if [ "$changesets_outcome" = failure ] || [ "$changesets_outcome" = cancelled ] \ + || [ "$qualify_outcome" = failure ] || [ "$qualify_outcome" = cancelled ] \ + || [ "$registry_outcome" = failure ] || [ "$registry_outcome" = cancelled ] \ + || [ "$job_status" = failure ] || [ "$job_status" = cancelled ]; then + outcome=failed +elif [ "$published" = true ] && [ "$publish_enabled" = true ]; then + outcome=published +elif [ "$qualify_outcome" = success ]; then + outcome=qualified-without-publish +else + outcome=version-maintenance-only +fi + +if [ "$has_changesets" = true ]; then + version_maintenance=executed +else + version_maintenance=skipped +fi + +if [ "$qualify_outcome" = success ] || [ "$published" = true ]; then + qualification=executed +elif [ "$qualify_outcome" = failure ] || [ "$qualify_outcome" = cancelled ]; then + qualification=failed +else + qualification=skipped +fi + +if [ "$published" = true ]; then + publication=executed +else + publication=skipped +fi + +if [ "$registry_outcome" = success ]; then + registry=executed +elif [ "$registry_outcome" = failure ]; then + registry=failed +else + registry=skipped +fi + +if [ "$outcome" = published ]; then + publication_line='publication: published' + candidate_sha=$workflow_sha +else + publication_line='publication: NOT PUBLISHED' + if [ "$qualification" = executed ]; then + candidate_sha=$workflow_sha + else + candidate_sha='(not qualified)' + fi +fi + +cat < `- ${ref}`).join("\n")); + console.log(""); + console.log("test groups:"); + for (const group of evidence.testGroups ?? []) console.log(`- ${group}: executed`); + console.log(""); + console.log("executed bins:"); + const bins = evidence.executedBins ?? []; + if (bins.length === 0) console.log("- (none)"); + for (const bin of bins) console.log(`- ${bin}`); + ' +fi + +if [ "$qualification" = executed ]; then + echo + echo "follow-ups vs ${candidate_sha}:" + while IFS=$'\t' read -r issue_number issue_label || [ -n "${issue_number:-}" ]; do + [ -z "${issue_number:-}" ] && continue + state=pending + if command -v gh >/dev/null 2>&1 && { [ -n "${GITHUB_TOKEN:-}" ] || [ -n "${GH_TOKEN:-}" ]; }; then + state=$(gh issue view "$issue_number" --repo "${GITHUB_REPOSITORY:-ScriptedAlchemy/agent-bundle}" \ + --json state --jq .state 2>/dev/null || echo pending) + state=$(printf '%s' "$state" | tr '[:upper:]' '[:lower:]') + fi + echo "- #$issue_number $issue_label: $state" + issue_number= + done <<'EOF' +680 executable/preflight selection +681 retention +683 native acceptance +685 legacy purge +686 production Flight streaming +688 schema-label provenance +EOF +fi diff --git a/scripts/run-packed-tests.mjs b/scripts/run-packed-tests.mjs index 0d1249786..fbcf5cec2 100644 --- a/scripts/run-packed-tests.mjs +++ b/scripts/run-packed-tests.mjs @@ -10,6 +10,7 @@ * pass through to rstest. */ import { execFile as executeFile, spawn } from 'node:child_process'; +import { createHash } from 'node:crypto'; import { cp, mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { dirname, join, resolve } from 'node:path'; @@ -60,28 +61,83 @@ try { sharedPacks.set(packageName, pack); await writeFile(join(packDirectory, `${packageName}.json`), `${JSON.stringify(pack)}\n`); })); + const dependencyFields = ['dependencies', 'optionalDependencies', 'peerDependencies']; + const packedRecords = []; + for (const pack of sharedPacks.values()) { + const { stdout } = await execFile('tar', ['-xOf', pack.tarball, 'package/package.json']); + packedRecords.push({ + digest: createHash('sha256').update(await readFile(pack.tarball)).digest('hex'), + filename: pack.packOutput.filename, + manifest: JSON.parse(stdout), + tarball: pack.tarball, + }); + } + const siblingNames = new Set(packedRecords.map((record) => record.manifest.name)); + const workspaceRefs = []; + const interPackageRanges = []; + for (const record of packedRecords) { + for (const field of dependencyFields) { + const section = record.manifest[field]; + if (section === undefined || typeof section !== 'object' || section === null) continue; + for (const [name, specifier] of Object.entries(section)) { + if (typeof specifier === 'string' && specifier.startsWith('workspace:')) { + workspaceRefs.push(`${record.manifest.name} ${field} ${name} ${specifier}`); + } + if (siblingNames.has(name)) { + interPackageRanges.push({ + field, + name, + package: record.manifest.name, + specifier, + }); + } + } + } + } + if (workspaceRefs.length > 0) { + throw new Error(`packed manifests still carry workspace: ranges:\n${workspaceRefs.join('\n')}`); + } const binConsumer = join(packDirectory, 'bin-consumer'); await mkdir(binConsumer); await writeFile(join(binConsumer, 'package.json'), '{"private":true}\n'); - const binPackages = ['agent-bundle', 'create-agent-bundle']; await execFile('npm', [ 'install', '--ignore-scripts', '--no-audit', '--no-fund', '--prefer-offline', - ...binPackages.map((name) => sharedPacks.get(name).tarball), + ...packedRecords.map((record) => record.tarball), ], { cwd: binConsumer, env: environment }); - for (const packageName of binPackages) { - const packageDocument = JSON.parse(await readFile(join(binConsumer, 'node_modules', packageName, 'package.json'), 'utf8')); - const bins = typeof packageDocument.bin === 'string' - ? [[packageDocument.name.replace(/^@[^/]+\//u, ''), packageDocument.bin]] - : Object.entries(packageDocument.bin ?? {}); - for (const [name] of bins) { + const executedBins = []; + for (const record of packedRecords) { + const bins = typeof record.manifest.bin === 'string' + ? [record.manifest.name.replace(/^@[^/]+\//u, '')] + : Object.keys(record.manifest.bin ?? {}); + for (const name of bins) { const executable = join(binConsumer, 'node_modules', '.bin', process.platform === 'win32' ? `${name}.cmd` : name); await execFile(executable, ['--help'], { cwd: binConsumer, env: environment }); + executedBins.push(name); } } + const evidencePath = environment.AGENT_BUNDLE_RELEASE_EVIDENCE; + if (typeof evidencePath === 'string' && evidencePath.length > 0) { + await writeFile(evidencePath, `${JSON.stringify({ + candidateSha: environment.GITHUB_SHA ?? '', + executedBins, + interPackageRanges, + packages: packedRecords.map((record) => ({ + bins: typeof record.manifest.bin === 'string' + ? [record.manifest.name.replace(/^@[^/]+\//u, '')] + : Object.keys(record.manifest.bin ?? {}), + digest: record.digest, + name: record.manifest.name, + tarball: record.filename, + version: record.manifest.version, + })), + testGroups: releasePool ? ['packed', 'packed-release'] : ['packed'], + workspaceRefs, + }, null, 2)}\n`); + } // Build the synthetic private sibling into a separate package image. The // normal dist and shared release tarball above remain the publish candidate. const fixtureDist = join(packDirectory, 'runtime-rebundle-dist'); diff --git a/website/docs/en/contributing/index.mdx b/website/docs/en/contributing/index.mdx index 748f32f4c..c9e77e048 100644 --- a/website/docs/en/contributing/index.mdx +++ b/website/docs/en/contributing/index.mdx @@ -75,6 +75,11 @@ workspace packages, including the examples and this site, are not versioned or t Nothing is published to npm yet. The current release channel is the pkg.pr.new preview tarballs described in [Preview packages](../guide/distribution/preview-packages.mdx). +Each `Release packages` run writes an explicit outcome to the workflow summary: +`version-maintenance-only` (Version Packages PR refreshed), `qualified-without-publish` +(the exact versioned candidate passed `pnpm check:release`), `published`, or `failed`. +Disabled publication reports **NOT PUBLISHED**; registry verification runs only when +publication is separately authorized. ## Native host smokes are opt-in diff --git a/website/docs/en/guide/distribution/preview-packages.mdx b/website/docs/en/guide/distribution/preview-packages.mdx index f03bff0ad..0dfef3453 100644 --- a/website/docs/en/guide/distribution/preview-packages.mdx +++ b/website/docs/en/guide/distribution/preview-packages.mdx @@ -70,6 +70,14 @@ Before that path is enabled, the release owner has to resolve two things: the fi and license, and the repository-wide `"access": "restricted"` policy for `agent-bundle`, which is not currently overridden with `publishConfig.access`. +The `Release packages` workflow records one of `version-maintenance-only`, +`qualified-without-publish`, `published`, or `failed`, with executed and skipped +stages. Qualification runs against the exact versioned candidate SHA, not the +pre-version main commit. A successful Version Packages refresh — or a main push that neither +refreshes that PR nor qualifies a versioned candidate — is +`version-maintenance-only`. Disabled publication reports **NOT PUBLISHED**; +registry verification runs only when publication is separately authorized. + `pnpm release` runs the release gate — `pnpm pack:dry-run`, `pnpm lint:release`, and `pnpm test:packed:release` — before publishing. `lint:release` runs `attw` with the `esm-only` profile on the packed `agent-bundle`, `@agent-bundle/runtime`, `rsc-markdown-stream`, and diff --git a/website/docs/zh/contributing/index.mdx b/website/docs/zh/contributing/index.mdx index 80305b3d7..c506729d4 100644 --- a/website/docs/zh/contributing/index.mdx +++ b/website/docs/zh/contributing/index.mdx @@ -69,6 +69,10 @@ pnpm changeset 目前还没有任何东西发布到 npm。当前的发布通道是 pkg.pr.new 预览 tarball,见 [预览包](../guide/distribution/preview-packages.mdx)。 +每次 `Release packages` 运行都会在工作流摘要中写出明确结果:`version-maintenance-only` +(刷新了 Version Packages PR)、`qualified-without-publish`(精确的版本化候选通过了 +`pnpm check:release`)、`published` 或 `failed`。未启用发布时摘要写明 **NOT PUBLISHED**; +只有在发布被单独授权时才会做注册表核验。 ## 原生宿主 smoke 需要显式开启 diff --git a/website/docs/zh/guide/distribution/preview-packages.mdx b/website/docs/zh/guide/distribution/preview-packages.mdx index a3c858458..ef2b98e87 100644 --- a/website/docs/zh/guide/distribution/preview-packages.mdx +++ b/website/docs/zh/guide/distribution/preview-packages.mdx @@ -60,6 +60,12 @@ npx https://pkg.pr.new/ScriptedAlchemy/agent-bundle/create-agent-bundle@