diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 6c3957c..a19755f 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -6,7 +6,7 @@ on: workflow_dispatch: permissions: - contents: read + contents: write jobs: package: @@ -34,19 +34,51 @@ jobs: env: CONFORMANCE_CLEAN_CHECKOUT: "1" - run: mkdir -p release-artifacts + - name: Record release metadata + run: pnpm release:metadata --output release-artifacts/release-metadata.json - name: Include conformance record run: tar -czf release-artifacts/erc-8350-conformance.tar.gz -C artifacts conformance - run: pnpm --dir packages/core pack --pack-destination ../../release-artifacts - run: pnpm --dir packages/reference-engine pack --pack-destination ../../release-artifacts - run: pnpm --dir packages/awareness-adapter pack --pack-destination ../../release-artifacts + - run: pnpm --dir implementations/minimal-ts pack --pack-destination ../../release-artifacts - name: Package contract sources run: >- tar -czf release-artifacts/agent-memory-state-contracts.tar.gz contracts/src contracts/foundry.toml contracts/remappings.txt + - name: Package normative specification and vectors + run: >- + tar -czf release-artifacts/erc-8350-specification.tar.gz + erc/erc-8350.md erc/assets/erc-8350 SPEC.md + test-vectors/v1.json test-vectors/v2.json LICENSE + - name: Package G4 security evidence + run: >- + tar -czf release-artifacts/erc-8350-g4-security-evidence.tar.gz + audits/g4 SECURITY.md docs/security - name: Generate checksums run: cd release-artifacts && sha256sum * > SHA256SUMS - uses: actions/upload-artifact@v4 with: - name: erc-awar-${{ github.run_id }} + name: erc-8350-${{ github.run_id }} path: release-artifacts/ if-no-files-found: error + - name: Publish GitHub release + if: github.ref_type == 'tag' + env: + GH_TOKEN: ${{ github.token }} + shell: bash + run: | + prerelease_args=() + if [[ "${GITHUB_REF_NAME#v}" == *-* ]]; then + prerelease_args+=(--prerelease) + fi + + if gh release view "$GITHUB_REF_NAME" >/dev/null 2>&1; then + gh release upload "$GITHUB_REF_NAME" release-artifacts/* --clobber + else + gh release create "$GITHUB_REF_NAME" release-artifacts/* \ + --verify-tag \ + --generate-notes \ + --title "ERC-8350 ${GITHUB_REF_NAME#v}" \ + "${prerelease_args[@]}" + fi diff --git a/audits/g4/README.md b/audits/g4/README.md index 23efe96..04385fb 100644 --- a/audits/g4/README.md +++ b/audits/g4/README.md @@ -83,7 +83,9 @@ finding, and zero unresolved Critical or High findings. 6. Map accepted non-Critical/High findings to `residual-risks.json`. 7. Run `pnpm security:rc`, `pnpm check`, and the clean-checkout conformance gate. 8. Obtain maintainer approval of reviewer identity and report quality, then mark - G4 complete and create the RC tag. + G4 complete, update every workspace package to the RC SemVer, and create the + matching `v` tag. The release workflow will re-run the full gate before + publishing the GitHub prerelease. The script checks evidence integrity. It cannot establish reviewer competence or independence on its own; those remain explicit human approval duties. diff --git a/docs/release-process.md b/docs/release-process.md index 864e7b9..a1462e0 100644 --- a/docs/release-process.md +++ b/docs/release-process.md @@ -9,7 +9,7 @@ the draft an accepted ERC and do not imply that contracts are production ready. - `1.0.0-beta.x`: v1 encoding is frozen and at least one external implementation has passed the golden vectors. - `1.0.0-rc.x`: editor feedback is incorporated, testnet deployments are public, - and an external security review has no unresolved critical findings. + and an external security review has no unresolved Critical or High findings. - `1.0.0`: the team declares the implementation stable. ERC status remains the status shown in the official ERC repository. @@ -29,8 +29,11 @@ the draft an accepted ERC and do not imply that contracts are production ready. 8. Manually verify reviewer identity, expertise, conflict disclosure, report quality, and the public attestation. Passing JSON validation alone is not an audit. -9. Create a signed `v*` tag. The release workflow builds package tarballs, - contract sources, and SHA-256 checksums without publishing automatically. +9. Update every workspace package to the same SemVer, then create a signed tag + whose name is exactly `v`. The release workflow re-runs the gates, + builds package tarballs, contract sources, the normative specification, + vectors, security evidence, and SHA-256 checksums, then creates a GitHub + prerelease for pre-release versions. It does not publish npm packages. 10. Attach audit and testnet deployment references before labeling an artifact production ready. diff --git a/package.json b/package.json index 211f141..ca7ef60 100644 --- a/package.json +++ b/package.json @@ -31,12 +31,14 @@ "security:scope": "node scripts/security-rc-gate.mjs --scope-only && pnpm security:handoff", "security:test": "node --test scripts/security-rc-gate.test.mjs scripts/security-audit-handoff.test.mjs", "security:rc": "pnpm security:handoff && node scripts/security-rc-gate.mjs", + "release:metadata": "node scripts/release-metadata-gate.mjs", + "release:test": "node --test scripts/release-metadata-gate.test.mjs", "test": "pnpm test:ts && pnpm check:vectors && pnpm test:contracts", - "check": "pnpm clean && pnpm build && pnpm -r --if-present typecheck && pnpm -r --if-present test && pnpm check:erc-assets && pnpm check:vectors && pnpm check:private-witness && pnpm evidence:validate && pnpm evidence:test && pnpm security:scope && pnpm security:test && pnpm format:check && pnpm test:contracts", + "check": "pnpm clean && pnpm build && pnpm -r --if-present typecheck && pnpm -r --if-present test && pnpm check:erc-assets && pnpm check:vectors && pnpm check:private-witness && pnpm evidence:validate && pnpm evidence:test && pnpm security:scope && pnpm security:test && pnpm release:test && pnpm format:check && pnpm test:contracts", "conformance:gate": "node scripts/conformance-gate.mjs", "conformance:clean": "node scripts/clean-checkout-conformance.mjs", "audit": "pnpm audit --audit-level=moderate", - "release:check": "pnpm conformance:gate && pnpm security:rc && pnpm audit --audit-level=moderate" + "release:check": "pnpm release:metadata && pnpm release:test && pnpm conformance:gate && pnpm security:rc && pnpm audit --audit-level=moderate" }, "devDependencies": { "@noble/curves": "^2.2.0", diff --git a/scripts/release-metadata-gate.mjs b/scripts/release-metadata-gate.mjs new file mode 100644 index 0000000..6987a97 --- /dev/null +++ b/scripts/release-metadata-gate.mjs @@ -0,0 +1,155 @@ +import { execFileSync } from "node:child_process"; +import { mkdir, readFile, readdir, writeFile } from "node:fs/promises"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const scriptDirectory = path.dirname(fileURLToPath(import.meta.url)); +const defaultRepositoryRoot = path.resolve(scriptDirectory, ".."); +const semverPattern = + /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/u; + +function isValidSemver(version) { + const match = semverPattern.exec(version); + if (!match) return false; + + const prerelease = match[4]; + return ( + !prerelease || + prerelease + .split(".") + .every((identifier) => !/^\d+$/u.test(identifier) || !/^0\d+/u.test(identifier)) + ); +} + +function parseArguments(argv) { + const options = { + output: null, + repositoryRoot: defaultRepositoryRoot, + requireTag: false, + tag: null, + }; + + for (let index = 0; index < argv.length; index += 1) { + const argument = argv[index]; + if (argument === "--require-tag") { + options.requireTag = true; + continue; + } + if (argument === "--output" || argument === "--root" || argument === "--tag") { + const value = argv[index + 1]; + if (!value) throw new Error(`${argument} requires a value`); + index += 1; + if (argument === "--output") options.output = value; + if (argument === "--root") options.repositoryRoot = path.resolve(value); + if (argument === "--tag") options.tag = value; + continue; + } + throw new Error(`Unknown argument: ${argument}`); + } + + return options; +} + +async function readJson(filePath) { + return JSON.parse(await readFile(filePath, "utf8")); +} + +async function collectPackageManifests(repositoryRoot) { + const manifestPaths = ["package.json"]; + + for (const parent of ["packages", "implementations"]) { + const entries = await readdir(path.join(repositoryRoot, parent), { + withFileTypes: true, + }); + for (const entry of entries) { + if (entry.isDirectory()) { + manifestPaths.push(path.join(parent, entry.name, "package.json")); + } + } + } + + const manifests = []; + for (const relativePath of manifestPaths.sort()) { + const manifest = await readJson(path.join(repositoryRoot, relativePath)); + if (!manifest.name || !manifest.version) { + throw new Error(`${relativePath} must define name and version`); + } + manifests.push({ + name: manifest.name, + path: relativePath, + version: manifest.version, + }); + } + return manifests; +} + +function inferTag(explicitTag) { + if (explicitTag) return explicitTag; + if (process.env.GITHUB_REF_TYPE === "tag") return process.env.GITHUB_REF_NAME; + if (process.env.GITHUB_REF?.startsWith("refs/tags/")) { + return process.env.GITHUB_REF.slice("refs/tags/".length); + } + return null; +} + +function gitValue(repositoryRoot, ...arguments_) { + return execFileSync("git", ["-C", repositoryRoot, ...arguments_], { + encoding: "utf8", + }).trim(); +} + +async function main() { + const options = parseArguments(process.argv.slice(2)); + const manifests = await collectPackageManifests(options.repositoryRoot); + const rootManifest = manifests.find((manifest) => manifest.path === "package.json"); + const version = rootManifest.version; + + if (!isValidSemver(version)) { + throw new Error(`Root package version is not valid SemVer: ${version}`); + } + + const mismatches = manifests.filter((manifest) => manifest.version !== version); + if (mismatches.length > 0) { + throw new Error( + `Workspace package versions differ from ${version}: ${mismatches + .map((manifest) => `${manifest.path}=${manifest.version}`) + .join(", ")}`, + ); + } + + const tag = inferTag(options.tag); + if (options.requireTag && !tag) { + throw new Error("A release tag is required"); + } + if (tag && tag !== `v${version}`) { + throw new Error(`Release tag ${tag} must equal v${version}`); + } + + const result = { + schema: "erc-8350/release-metadata/v1", + version, + tag, + prerelease: version.includes("-"), + source: { + commit: gitValue(options.repositoryRoot, "rev-parse", "HEAD"), + tree: gitValue(options.repositoryRoot, "rev-parse", "HEAD^{tree}"), + }, + packages: manifests, + }; + + if (options.output) { + const outputPath = path.resolve(options.repositoryRoot, options.output); + await mkdir(path.dirname(outputPath), { recursive: true }); + await writeFile(outputPath, `${JSON.stringify(result, null, 2)}\n`); + } + + console.log(`Release metadata: PASS (${manifests.length} package manifests)`); + console.log(`Version: ${version}`); + console.log(`Tag: ${tag ?? "not supplied"}`); +} + +main().catch((error) => { + console.error("Release metadata: FAIL"); + console.error(error instanceof Error ? error.message : String(error)); + process.exitCode = 1; +}); diff --git a/scripts/release-metadata-gate.test.mjs b/scripts/release-metadata-gate.test.mjs new file mode 100644 index 0000000..296708b --- /dev/null +++ b/scripts/release-metadata-gate.test.mjs @@ -0,0 +1,145 @@ +import assert from "node:assert/strict"; +import { mkdtemp, mkdir, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { spawnSync } from "node:child_process"; +import test from "node:test"; +import { fileURLToPath } from "node:url"; + +const scriptDirectory = path.dirname(fileURLToPath(import.meta.url)); +const repositoryRoot = path.resolve(scriptDirectory, ".."); +const gate = path.join(scriptDirectory, "release-metadata-gate.mjs"); + +function runGate(arguments_, root = repositoryRoot) { + return spawnSync(process.execPath, [gate, "--root", root, ...arguments_], { + cwd: repositoryRoot, + encoding: "utf8", + env: { + ...process.env, + GITHUB_REF: "", + GITHUB_REF_NAME: "", + GITHUB_REF_TYPE: "", + }, + }); +} + +function outputOf(result) { + return `${result.stdout}\n${result.stderr}`; +} + +async function createWorkspace(versions) { + const root = await mkdtemp(path.join(tmpdir(), "erc8350-release-test-")); + await mkdir(path.join(root, "packages", "core"), { recursive: true }); + await mkdir(path.join(root, "implementations", "minimal-ts"), { + recursive: true, + }); + await writeFile( + path.join(root, "package.json"), + `${JSON.stringify({ name: "root", version: versions.root })}\n`, + ); + await writeFile( + path.join(root, "packages", "core", "package.json"), + `${JSON.stringify({ name: "core", version: versions.core })}\n`, + ); + await writeFile( + path.join(root, "implementations", "minimal-ts", "package.json"), + `${JSON.stringify({ name: "minimal", version: versions.minimal })}\n`, + ); + return root; +} + +test("accepts the canonical workspace version without a tag", () => { + const result = runGate([]); + assert.equal(result.status, 0, outputOf(result)); + assert.match(result.stdout, /Release metadata: PASS/u); + assert.match(result.stdout, /Tag: not supplied/u); +}); + +test("accepts a tag that exactly matches the package version", () => { + const result = runGate(["--tag", "v1.0.0-alpha.1", "--require-tag"]); + assert.equal(result.status, 0, outputOf(result)); + assert.match(result.stdout, /Tag: v1\.0\.0-alpha\.1/u); +}); + +test("writes reproducible release metadata", async () => { + const outputDirectory = await mkdtemp(path.join(tmpdir(), "erc8350-release-output-")); + const outputPath = path.join(outputDirectory, "release-metadata.json"); + try { + const result = runGate([ + "--tag", + "v1.0.0-alpha.1", + "--require-tag", + "--output", + outputPath, + ]); + assert.equal(result.status, 0, outputOf(result)); + + const metadata = JSON.parse(await readFile(outputPath, "utf8")); + assert.equal(metadata.schema, "erc-8350/release-metadata/v1"); + assert.equal(metadata.version, "1.0.0-alpha.1"); + assert.equal(metadata.tag, "v1.0.0-alpha.1"); + assert.equal(metadata.prerelease, true); + assert.match(metadata.source.commit, /^[0-9a-f]{40}$/u); + assert.match(metadata.source.tree, /^[0-9a-f]{40}$/u); + assert.equal(metadata.packages.length, 6); + } finally { + await rm(outputDirectory, { recursive: true, force: true }); + } +}); + +test("rejects a tag that differs from the package version", () => { + const result = runGate(["--tag", "v1.0.0-rc.1", "--require-tag"]); + assert.notEqual(result.status, 0); + assert.match(outputOf(result), /must equal v1\.0\.0-alpha\.1/u); +}); + +test("rejects a release run without a tag", () => { + const result = runGate(["--require-tag"]); + assert.notEqual(result.status, 0); + assert.match(outputOf(result), /A release tag is required/u); +}); + +test("rejects inconsistent workspace package versions", async () => { + const root = await createWorkspace({ + root: "1.0.0-rc.1", + core: "1.0.0-rc.1", + minimal: "1.0.0-alpha.1", + }); + try { + const result = runGate([], root); + assert.notEqual(result.status, 0); + assert.match(outputOf(result), /Workspace package versions differ/u); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + +test("rejects a non-SemVer root version", async () => { + const root = await createWorkspace({ + root: "release-candidate", + core: "release-candidate", + minimal: "release-candidate", + }); + try { + const result = runGate([], root); + assert.notEqual(result.status, 0); + assert.match(outputOf(result), /not valid SemVer/u); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + +test("rejects a prerelease numeric identifier with a leading zero", async () => { + const root = await createWorkspace({ + root: "1.0.0-rc.01", + core: "1.0.0-rc.01", + minimal: "1.0.0-rc.01", + }); + try { + const result = runGate([], root); + assert.notEqual(result.status, 0); + assert.match(outputOf(result), /not valid SemVer/u); + } finally { + await rm(root, { recursive: true, force: true }); + } +});