From bf608ba6fa97209a786257a28d70508e6bad803e Mon Sep 17 00:00:00 2001 From: JUN Date: Wed, 11 Nov 2026 12:23:04 +0900 Subject: [PATCH] ci(release): require service evidence before packaging starts Run 42951296881 resolved its CI evidence in validate-dispatch, started every packaging job, and only then failed in verify-npm: no service-lifecycle run existed for the source. Packaging cannot create that evidence, so the minutes were spent on a release that could never publish, and the error only named the workflow to dispatch. validate-dispatch now runs the same CI and service evidence script that verify-npm and publish repeat, right after the version is resolved, so a release missing service evidence fails in seconds. The error now carries the exact gh workflow run command for the source. Contract tests pin that the early step is byte-identical to the later rechecks, reads the resolved source and version, and runs after version resolution. --- .github/workflows/release.yml | 40 ++++++++++++++++++- structure/ops/docs-and-release.md | 2 +- tests/ci-workflows/ci-workflows.test.ts | 6 +-- .../release-pipeline-contract.test.ts | 17 +++++++- 4 files changed, 58 insertions(+), 7 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 6cb29b6a761..17c5736edf1 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -154,6 +154,42 @@ jobs: appendFileSync(process.env.GITHUB_OUTPUT, `version=${version}\n`); ' + # The same evidence gate verify-npm and publish repeat, answered before any runner packages. + # Run 42951296881 resolved its CI evidence here, packaged for minutes and only then failed in + # verify-npm because no service-lifecycle run existed for the source. Packaging cannot create + # that evidence, so a release that lacks it now fails in seconds with the dispatch command. + - name: Require source CI and service evidence before packaging + env: + GH_TOKEN: ${{ github.token }} + SOURCE_SHA: ${{ steps.source.outputs.source_sha }} + RELEASE_VERSION: ${{ steps.version.outputs.version }} + run: | + set -euo pipefail + node .github/scripts/release-evidence.cjs check "$SOURCE_SHA" + baseline_tag="$(git tag --merged "$SOURCE_SHA" --list 'v[0-9]*' | bun -e ' + import { parseVersion, compareVersions } from "./scripts/version-line.ts"; + const tags = (await Bun.stdin.text()).trim().split("\n").filter(Boolean); + const versions = tags.map(tag => ({ tag, version: tag.slice(1) })); + if (versions.some(item => !parseVersion(item.version))) throw new Error("invalid release tag"); + const eligible = versions.filter(item => compareVersions(item.version, process.env.RELEASE_VERSION) < 0); + eligible.sort((a, b) => compareVersions(a.version, b.version)); + console.log(eligible.at(-1)?.tag ?? ""); + ')" + baseline=--initial + if [ -n "$baseline_tag" ]; then + draft="$(gh release view "$baseline_tag" --json isDraft --jq .isDraft)" + test "$draft" = false || { echo '::error::Service baseline is not a published release.'; exit 1; } + baseline="$(git rev-parse "refs/tags/${baseline_tag}^{commit}")" + fi + range="$(node .github/scripts/release-evidence.cjs service-range "$SOURCE_SHA" "$baseline")" + required="$(RANGE="$range" node -e 'const x = JSON.parse(process.env.RANGE); if (typeof x.required !== "boolean") throw Error("invalid service range"); process.stdout.write(String(x.required));')" + if [ "$required" = true ]; then + node .github/scripts/release-evidence.cjs service "$SOURCE_SHA" || { + echo "::error::Dispatch service-lifecycle.yml from dev for source ${SOURCE_SHA} and wait for all platforms: gh workflow run service-lifecycle.yml --ref dev -f source_sha=${SOURCE_SHA}"; exit 1; + } + fi + + # Every publication precondition the dispatch can already decide, checked before any runner # starts packaging: channel and dist-tag, every version source, the tag, the GitHub release, # npm and the global tag ordering (scripts/ci/release-preflight.sh). @@ -1069,7 +1105,7 @@ jobs: required="$(RANGE="$range" node -e 'const x = JSON.parse(process.env.RANGE); if (typeof x.required !== "boolean") throw Error("invalid service range"); process.stdout.write(String(x.required));')" if [ "$required" = true ]; then node .github/scripts/release-evidence.cjs service "$SOURCE_SHA" || { - echo "::error::Dispatch service-lifecycle.yml from dev for source ${SOURCE_SHA} and wait for all platforms."; exit 1; + echo "::error::Dispatch service-lifecycle.yml from dev for source ${SOURCE_SHA} and wait for all platforms: gh workflow run service-lifecycle.yml --ref dev -f source_sha=${SOURCE_SHA}"; exit 1; } fi @@ -1570,7 +1606,7 @@ jobs: required="$(RANGE="$range" node -e 'const x = JSON.parse(process.env.RANGE); if (typeof x.required !== "boolean") throw Error("invalid service range"); process.stdout.write(String(x.required));')" if [ "$required" = true ]; then node .github/scripts/release-evidence.cjs service "$SOURCE_SHA" || { - echo "::error::Dispatch service-lifecycle.yml from dev for source ${SOURCE_SHA} and wait for all platforms."; exit 1; + echo "::error::Dispatch service-lifecycle.yml from dev for source ${SOURCE_SHA} and wait for all platforms: gh workflow run service-lifecycle.yml --ref dev -f source_sha=${SOURCE_SHA}"; exit 1; } fi diff --git a/structure/ops/docs-and-release.md b/structure/ops/docs-and-release.md index 5d5f6a0ccb3..49e072682bf 100644 --- a/structure/ops/docs-and-release.md +++ b/structure/ops/docs-and-release.md @@ -147,7 +147,7 @@ released history remains reachable through tags. No repository setting change or deploy is implied by this documentation. Service lifecycle evidence includes the legacy CLI entrypoint `src/cli.ts` as well as the -service implementation and desktop packaging paths. +service implementation and desktop packaging paths. `validate-dispatch` in `release.yml` checks it with the CI evidence before any packaging job starts, and `verify-npm` and `publish` recheck it; a missing run fails with the `gh workflow run service-lifecycle.yml --ref dev -f source_sha=` command. > Decision record: [ADR-0080](../decisions/ADR-0080-github-pages.md) diff --git a/tests/ci-workflows/ci-workflows.test.ts b/tests/ci-workflows/ci-workflows.test.ts index 1bb182c09ee..0648b5f497d 100644 --- a/tests/ci-workflows/ci-workflows.test.ts +++ b/tests/ci-workflows/ci-workflows.test.ts @@ -737,9 +737,9 @@ describe("GitHub Actions hardening", () => { const packageJson = JSON.parse(await readText("package.json")) as { scripts?: Record }; expect(packageJson.scripts?.prepublishOnly).toContain("bun run audit:high"); expect(workflow).toContain("npm run prepublishOnly"); - expect(count(workflow, 'node .github/scripts/release-evidence.cjs check "$SOURCE_SHA"')).toBe(2); - expect(count(workflow, 'node .github/scripts/release-evidence.cjs service-range "$SOURCE_SHA" "$baseline"')).toBe(2); - expect(count(workflow, 'node .github/scripts/release-evidence.cjs service "$SOURCE_SHA"')).toBe(2); + expect(count(workflow, 'node .github/scripts/release-evidence.cjs check "$SOURCE_SHA"')).toBe(3); + expect(count(workflow, 'node .github/scripts/release-evidence.cjs service-range "$SOURCE_SHA" "$baseline"')).toBe(3); + expect(count(workflow, 'node .github/scripts/release-evidence.cjs service "$SOURCE_SHA"')).toBe(3); // Dry-run first by default; tokenless trusted publishing only. expect(workflow).toMatch(/dry-run:[\s\S]*?default: true/); diff --git a/tests/ci-workflows/release-pipeline-contract.test.ts b/tests/ci-workflows/release-pipeline-contract.test.ts index b40fec00ef6..d5bfeef8b5c 100644 --- a/tests/ci-workflows/release-pipeline-contract.test.ts +++ b/tests/ci-workflows/release-pipeline-contract.test.ts @@ -517,6 +517,18 @@ describe("release pipeline contract", () => { const publish = release.jobs?.publish?.steps?.find(step => step.name === "Recheck source CI and service evidence")?.run; expect(verify).toBeDefined(); expect(publish).toBe(verify); + // validate-dispatch answers the same gate before any runner packages (run 42951296881 packaged + // and then failed in verify-npm on missing service evidence), with the identical script. + const early = release.jobs?.["validate-dispatch"]?.steps?.find(step => step.name === "Require source CI and service evidence before packaging") as + { run?: string; env?: Record } | undefined; + expect(early?.run).toBe(verify); + expect(early?.env).toMatchObject({ + SOURCE_SHA: "${{ steps.source.outputs.source_sha }}", + RELEASE_VERSION: "${{ steps.version.outputs.version }}", + }); + const earlySteps = release.jobs?.["validate-dispatch"]?.steps?.map(step => step.name) ?? []; + expect(earlySteps.indexOf("Require source CI and service evidence before packaging")) + .toBeGreaterThan(earlySteps.indexOf("Resolve release version")); const root = mkdtempSync(join(tmpdir(), "ocx-release-evidence-")); try { const calls = join(root, "calls"), source = "a".repeat(40), baseline = "b".repeat(40); @@ -537,13 +549,16 @@ describe("release pipeline contract", () => { PATH: process.env.PATH ?? "/usr/bin:/bin", SOURCE_SHA: source, BASE_SHA: baseline, RELEASE_VERSION: "2.1.0", BASELINE: "yes", DRAFT: "false", REQUIRED: "true", CI_STATUS: "0", SERVICE_STATUS: "0", CALLS: calls, ...extra, }, stdout: "pipe", stderr: "pipe" }); - return { code: result.exitCode, calls: readFileSync(calls, "utf8"), stderr: result.stderr.toString() }; + return { code: result.exitCode, calls: readFileSync(calls, "utf8"), stderr: result.stderr.toString(), stdout: result.stdout.toString() }; }; const required = execute(); expect(required.code, required.stderr).toBe(0); expect(required.calls).toContain(`check ${source}`); expect(required.calls).toContain(`service-range ${source} ${baseline}`); expect(required.calls).toContain(`service ${source}`); + const missing = execute({ SERVICE_STATUS: "1" }); + expect(missing.code).not.toBe(0); + expect(missing.stdout).toContain(`gh workflow run service-lifecycle.yml --ref dev -f source_sha=${source}`); const unchanged = execute({ REQUIRED: "false" }); expect(unchanged.code, unchanged.stderr).toBe(0); expect(unchanged.calls).not.toContain(`service ${source}`);