ci(release): add publication eligibility producer

This commit is contained in:
Vincent Koc
2026-08-21 04:09:54 -07:00
parent 2b58136d75
commit 64f59cd84e
8 changed files with 365 additions and 7 deletions
@@ -0,0 +1,250 @@
name: Release Publication Eligibility
run-name: Release publication eligibility ${{ inputs.target_sha }}
on:
workflow_dispatch:
inputs:
target_sha:
description: Exact candidate commit SHA whose publication inventory will be checked
required: true
type: string
target_context_ref:
description: Canonical refs/tags/v<version> context for the unpublished candidate
required: true
type: string
tooling_sha:
description: Exact trusted release-publish workflow commit SHA
required: true
type: string
tooling_ref:
description: Exact trusted refs/tags/release-publish/... workflow ref
required: true
type: string
permissions:
actions: read
contents: read
concurrency:
group: release-publication-eligibility-${{ inputs.target_sha }}-${{ inputs.tooling_sha }}
cancel-in-progress: false
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
NODE_VERSION: "24.16.0"
jobs:
eligibility:
name: Collect publication eligibility
runs-on: ubuntu-24.04
timeout-minutes: 15
steps:
- name: Initialize evidence bundle
run: |
mkdir -p "$RUNNER_TEMP/publication-eligibility"
printf '%s\n' '{"publication_authorized":false,"status":"pending"}' \
> "$RUNNER_TEMP/publication-eligibility/producer-result.json"
- name: Checkout exact release tooling
id: checkout
continue-on-error: true
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
ref: ${{ inputs.tooling_sha }}
fetch-depth: 0
filter: blob:none
persist-credentials: false
submodules: false
- name: Verify exact target and tooling identity
id: identity
if: steps.checkout.outcome == 'success'
continue-on-error: true
env:
TARGET_CONTEXT_REF: ${{ inputs.target_context_ref }}
TARGET_SHA: ${{ inputs.target_sha }}
TOOLING_REF: ${{ inputs.tooling_ref }}
TOOLING_SHA: ${{ inputs.tooling_sha }}
WORKFLOW_REF: ${{ github.workflow_ref }}
WORKFLOW_SHA: ${{ github.workflow_sha }}
run: |
set -euo pipefail
[[ "$TARGET_SHA" =~ ^[0-9a-f]{40}$ ]] || {
echo "target_sha must be an exact lowercase commit SHA." >&2
exit 1
}
[[ "$TOOLING_SHA" =~ ^[0-9a-f]{40}$ ]] || {
echo "tooling_sha must be an exact lowercase commit SHA." >&2
exit 1
}
[[ "$TARGET_CONTEXT_REF" =~ ^refs/tags/v[^[:space:]]+$ ]] || {
echo "target_context_ref must be a qualified release tag context." >&2
exit 1
}
[[ "$TOOLING_REF" =~ ^refs/tags/release-publish/[0-9a-f]{12}-[1-9][0-9]*$ ]] || {
echo "tooling_ref must be an exact release-publish tag." >&2
exit 1
}
expected_workflow_ref="${GITHUB_REPOSITORY}/.github/workflows/release-publication-eligibility.yml@${TOOLING_REF}"
[[ "$TOOLING_REF" == "$GITHUB_REF" &&
"$TOOLING_SHA" == "$GITHUB_SHA" &&
"$TOOLING_SHA" == "$WORKFLOW_SHA" &&
"$WORKFLOW_REF" == "$expected_workflow_ref" &&
"$(git rev-parse HEAD)" == "$TOOLING_SHA" ]] || {
echo "publication eligibility must run from the exact requested tooling ref and SHA." >&2
exit 1
}
git cat-file -e "${TARGET_SHA}^{commit}" || {
echo "target_sha is not available from the trusted repository checkout." >&2
exit 1
}
- name: Setup trusted Node environment
id: setup
if: steps.identity.outcome == 'success'
continue-on-error: true
uses: ./.github/actions/setup-node-env
with:
cache-mode: restore
node-version: ${{ env.NODE_VERSION }}
install-bun: "false"
install-deps: "true"
frozen-lockfile: "true"
- name: Produce canonical ReleasePlan lock
id: release_plan
if: steps.setup.outcome == 'success'
continue-on-error: true
env:
GH_TOKEN: ${{ github.token }}
TARGET_CONTEXT_REF: ${{ inputs.target_context_ref }}
TARGET_SHA: ${{ inputs.target_sha }}
TOOLING_REF: ${{ inputs.tooling_ref }}
TOOLING_SHA: ${{ inputs.tooling_sha }}
run: |
set -euo pipefail
mkdir -p "$RUNNER_TEMP/release-plan"
node --import tsx scripts/release-plan-producer.mts \
--intent publish \
--candidate-sha "$TARGET_SHA" \
--candidate-ref "$TARGET_CONTEXT_REF" \
--tooling-sha "$TOOLING_SHA" \
--tooling-full-ref "$TOOLING_REF" \
> "$RUNNER_TEMP/release-plan/release-plan-lock.json"
- name: Upload immutable ReleasePlan
id: release_plan_upload
if: steps.release_plan.outcome == 'success'
continue-on-error: true
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
with:
name: release-publication-plan-${{ github.run_id }}-${{ github.run_attempt }}
path: ${{ runner.temp }}/release-plan/release-plan-lock.json
if-no-files-found: error
retention-days: 30
- name: Bind ReleasePlan artifact digest
id: release_plan_identity
if: steps.release_plan_upload.outcome == 'success'
continue-on-error: true
env:
RAW_DIGEST: ${{ steps.release_plan_upload.outputs.artifact-digest }}
run: |
set -euo pipefail
[[ "$RAW_DIGEST" =~ ^[0-9a-f]{64}$ ]] || {
echo "ReleasePlan artifact digest must be 64 lowercase hex characters." >&2
exit 1
}
echo "digest=sha256:${RAW_DIGEST}" >> "$GITHUB_OUTPUT"
- name: Collect publication eligibility receipt
id: collect
if: steps.release_plan_identity.outcome == 'success'
continue-on-error: true
env:
GH_TOKEN: ${{ github.token }}
run: |
set -euo pipefail
node --import tsx scripts/release-publication-eligibility.mts \
--release-plan-lock "$RUNNER_TEMP/release-plan/release-plan-lock.json" \
--receipt "$RUNNER_TEMP/publication-eligibility/release-publication-eligibility.json" \
--repo-root "$GITHUB_WORKSPACE" \
--run-id "$GITHUB_RUN_ID" \
--run-attempt "$GITHUB_RUN_ATTEMPT" \
--job eligibility \
--artifact-id "${{ steps.release_plan_upload.outputs.artifact-id }}" \
--artifact-digest "${{ steps.release_plan_identity.outputs.digest }}" \
2> >(tee "$RUNNER_TEMP/publication-eligibility/collector-error.txt" >&2)
- name: Record producer outcome
if: always()
env:
CHECKOUT_OUTCOME: ${{ steps.checkout.outcome }}
COLLECT_OUTCOME: ${{ steps.collect.outcome }}
IDENTITY_OUTCOME: ${{ steps.identity.outcome }}
RELEASE_PLAN_IDENTITY_OUTCOME: ${{ steps.release_plan_identity.outcome }}
RELEASE_PLAN_OUTCOME: ${{ steps.release_plan.outcome }}
RELEASE_PLAN_UPLOAD_OUTCOME: ${{ steps.release_plan_upload.outcome }}
SETUP_OUTCOME: ${{ steps.setup.outcome }}
run: |
set -euo pipefail
receipt="$RUNNER_TEMP/publication-eligibility/release-publication-eligibility.json"
receipt_present=false
[[ -f "$receipt" ]] && receipt_present=true
jq -n \
--arg checkout "$CHECKOUT_OUTCOME" \
--arg identity "$IDENTITY_OUTCOME" \
--arg setup "$SETUP_OUTCOME" \
--arg release_plan "$RELEASE_PLAN_OUTCOME" \
--arg release_plan_upload "$RELEASE_PLAN_UPLOAD_OUTCOME" \
--arg release_plan_identity "$RELEASE_PLAN_IDENTITY_OUTCOME" \
--arg collect "$COLLECT_OUTCOME" \
--argjson receipt_present "$receipt_present" \
'{
schema: "openclaw.release-publication-eligibility-producer-result.v1",
publication_authorized: false,
checkout: $checkout,
identity: $identity,
setup: $setup,
release_plan: $release_plan,
release_plan_upload: $release_plan_upload,
release_plan_identity: $release_plan_identity,
collect: $collect,
receipt_present: $receipt_present
}' > "$RUNNER_TEMP/publication-eligibility/producer-result.json"
- name: Upload receipt or blocker bundle
id: evidence_upload
if: always()
continue-on-error: true
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
with:
name: release-publication-eligibility-${{ github.run_id }}-${{ github.run_attempt }}
path: ${{ runner.temp }}/publication-eligibility/*
if-no-files-found: error
retention-days: 30
- name: Fail blocked producer after evidence upload
if: always()
env:
CHECKOUT_OUTCOME: ${{ steps.checkout.outcome }}
COLLECT_OUTCOME: ${{ steps.collect.outcome }}
EVIDENCE_UPLOAD_OUTCOME: ${{ steps.evidence_upload.outcome }}
IDENTITY_OUTCOME: ${{ steps.identity.outcome }}
RELEASE_PLAN_IDENTITY_OUTCOME: ${{ steps.release_plan_identity.outcome }}
RELEASE_PLAN_OUTCOME: ${{ steps.release_plan.outcome }}
RELEASE_PLAN_UPLOAD_OUTCOME: ${{ steps.release_plan_upload.outcome }}
SETUP_OUTCOME: ${{ steps.setup.outcome }}
run: |
set -euo pipefail
[[ "$CHECKOUT_OUTCOME" == "success" &&
"$IDENTITY_OUTCOME" == "success" &&
"$SETUP_OUTCOME" == "success" &&
"$RELEASE_PLAN_OUTCOME" == "success" &&
"$RELEASE_PLAN_UPLOAD_OUTCOME" == "success" &&
"$RELEASE_PLAN_IDENTITY_OUTCOME" == "success" &&
"$COLLECT_OUTCOME" == "success" &&
"$EVIDENCE_UPLOAD_OUTCOME" == "success" ]] || {
echo "publication eligibility was blocked; inspect the uploaded evidence bundle." >&2
exit 1
}
-1
View File
@@ -8,7 +8,6 @@ Docs: https://docs.openclaw.ai
- **Secret egress host binding:** bind each shared-store secret to exact HTTPS destination hosts across CLI, Gateway RPC, and Control UI so unbound sentinel substitution fails closed before plaintext egress.
- **Release validation:** defer beta candidate Parallels smoke to postpublish `release:beta-smoke` by default, keep stable/full prepublish coverage, and bound nested release workflow monitors with explicit job timeouts.
- **Release publication eligibility:** collect npm and ClawHub version, trust, and vacancy blockers before validation from an immutable candidate snapshot, and emit five-minute validation-only receipts bound to the verified ReleasePlan and exact GitHub run provenance.
- **macOS app profiles:** isolate named app instances across state, preferences, Keychain, Gateway services, and duplicate-instance ownership while keeping host-global login and node services untouched.
- **Developer workflow:** remove the obsolete scoped-commit helper and use standard Git commands in isolated worktrees.
- **Plugin uninstall cleanup:** remove exact recorded install paths from `plugins.load.paths` for marketplace, npm, and other managed installs while preserving parent, child, prefix, and unrelated paths.
@@ -60,6 +60,7 @@ export type ReleasePublicationEligibilityReceiptBody = Omit<
export const RELEASE_PUBLICATION_ELIGIBILITY_CANONICALIZATION: "ascii-sorted-compact-json-trailing-newline-v1";
export const RELEASE_PUBLICATION_ELIGIBILITY_MAX_AGE_MS: number;
export const RELEASE_PUBLICATION_ELIGIBILITY_EVIDENCE_SCOPE: "validation-start-only";
export const RELEASE_PUBLICATION_ELIGIBILITY_WORKFLOW_PATH: ".github/workflows/release-publication-eligibility.yml";
export const RELEASE_PUBLICATION_NPM_REGISTRY: "https://registry.npmjs.org";
export const RELEASE_PUBLICATION_CLAWHUB_REGISTRY: "https://clawhub.ai";
@@ -9,6 +9,8 @@ export const RELEASE_PUBLICATION_ELIGIBILITY_CANONICALIZATION =
export const RELEASE_PUBLICATION_ELIGIBILITY_MAX_AGE_MS = 5 * 60_000;
const RELEASE_PUBLICATION_ELIGIBILITY_MAX_BYTES = 512 * 1024;
export const RELEASE_PUBLICATION_ELIGIBILITY_EVIDENCE_SCOPE = "validation-start-only";
export const RELEASE_PUBLICATION_ELIGIBILITY_WORKFLOW_PATH =
".github/workflows/release-publication-eligibility.yml";
export const RELEASE_PUBLICATION_NPM_REGISTRY = "https://registry.npmjs.org";
export const RELEASE_PUBLICATION_CLAWHUB_REGISTRY = "https://clawhub.ai";
@@ -504,11 +506,11 @@ export function verifyReleasePublicationEligibilityReceipt(
}
if (
provenance.repository !== lock.plan.tooling.repository ||
provenance.workflow_path !== lock.plan.tooling.workflow_path ||
provenance.workflow_path !== RELEASE_PUBLICATION_ELIGIBILITY_WORKFLOW_PATH ||
provenance.workflow_ref !== lock.plan.tooling.ref ||
provenance.workflow_sha !== lock.plan.tooling.sha
) {
fail("publication eligibility provenance does not match ReleasePlan tooling");
fail("publication eligibility provenance does not match its producer and ReleasePlan tooling");
}
const expectedNpm = lock.plan.inventory.packages
.filter((entry) => entry.targets.includes("npm"))
+2 -1
View File
@@ -33,6 +33,7 @@ import {
RELEASE_PUBLICATION_CLAWHUB_REGISTRY,
RELEASE_PUBLICATION_ELIGIBILITY_EVIDENCE_SCOPE,
RELEASE_PUBLICATION_ELIGIBILITY_MAX_AGE_MS,
RELEASE_PUBLICATION_ELIGIBILITY_WORKFLOW_PATH,
RELEASE_PUBLICATION_NPM_REGISTRY,
verifyReleasePublicationEligibilityReceipt,
type ReleasePublicationEligibilityProvenance,
@@ -925,7 +926,7 @@ export async function runReleasePublicationEligibilityCli(
}
const provenance: ReleasePublicationEligibilityProvenance = {
repository: lock.plan.tooling.repository,
workflow_path: lock.plan.tooling.workflow_path,
workflow_path: RELEASE_PUBLICATION_ELIGIBILITY_WORKFLOW_PATH,
workflow_ref: lock.plan.tooling.ref,
workflow_sha: lock.plan.tooling.sha,
run_id: requiredOption(args, "--run-id"),
+88
View File
@@ -48,6 +48,8 @@ const CREATE_GENERATED_PR_TOKENS_ACTION = ".github/actions/create-generated-pr-t
const PUBLISH_GENERATED_PR_ACTION = ".github/actions/publish-generated-pr/action.yml";
const SETUP_ANDROID_TOOLCHAIN_ACTION = ".github/actions/setup-android-toolchain/action.yml";
const MATURITY_SCORECARD_WORKFLOW = ".github/workflows/maturity-scorecard.yml";
const RELEASE_PUBLICATION_ELIGIBILITY_WORKFLOW =
".github/workflows/release-publication-eligibility.yml";
const MATURITY_SCORECARD_WORKFLOW_REF =
"openclaw/openclaw/.github/workflows/maturity-scorecard.yml@refs/heads/main";
const OIDC_BOUND_MAIN_REUSABLE_WORKFLOWS = new Set<string>();
@@ -69,6 +71,7 @@ const MATURITY_GENERATED_PR_PATHS = [
];
type WorkflowStep = {
"continue-on-error"?: boolean;
env?: Record<string, unknown>;
id?: string;
if?: string;
@@ -1504,6 +1507,91 @@ ${actionRun}`;
}
describe("ci workflow guards", () => {
it("keeps publication eligibility read-only, exact, and evidence-preserving", () => {
const source = readFileSync(RELEASE_PUBLICATION_ELIGIBILITY_WORKFLOW, "utf8");
const workflow = parse(source);
const inputs = workflow.on.workflow_dispatch.inputs;
const eligibility = workflow.jobs.eligibility;
const eligibilitySteps = eligibility.steps as WorkflowStep[];
const exactIdentity = expectDefined(
eligibilitySteps.find((step) => step.name === "Verify exact target and tooling identity"),
"publication eligibility exact identity step",
);
const setup = expectDefined(
eligibilitySteps.find((step) => step.name === "Setup trusted Node environment"),
"publication eligibility trusted Node setup step",
);
const produce = expectDefined(
eligibilitySteps.find((step) => step.name === "Produce canonical ReleasePlan lock"),
"publication eligibility ReleasePlan producer step",
);
const planUpload = expectDefined(
eligibilitySteps.find((step) => step.name === "Upload immutable ReleasePlan"),
"publication eligibility ReleasePlan upload step",
);
const collect = expectDefined(
eligibilitySteps.find((step) => step.name === "Collect publication eligibility receipt"),
"publication eligibility collector step",
);
const upload = expectDefined(
eligibilitySteps.find((step) => step.name === "Upload receipt or blocker bundle"),
"publication eligibility evidence upload step",
);
const fail = expectDefined(
eligibilitySteps.find((step) => step.name === "Fail blocked producer after evidence upload"),
"publication eligibility final failure step",
);
expect(Object.keys(inputs).toSorted()).toEqual([
"target_context_ref",
"target_sha",
"tooling_ref",
"tooling_sha",
]);
for (const input of Object.values(inputs) as Array<Record<string, unknown>>) {
expect(input).toMatchObject({ required: true, type: "string" });
}
expect(workflow.permissions).toEqual({ actions: "read", contents: "read" });
expect(source).not.toMatch(/\b(?:id-token|packages|statuses|contents):\s*write\b/u);
expect(source).not.toContain("environment:");
expect(source).not.toContain("secrets.");
expect(source).not.toContain("npm publish");
expect(source).not.toContain("clawhub publish");
expect(source).not.toContain("release-candidate-checklist");
expect(source).not.toContain("full-release-validation");
expect(exactIdentity.run).toContain('"$TOOLING_REF" == "$GITHUB_REF"');
expect(exactIdentity.run).toContain('"$TOOLING_SHA" == "$WORKFLOW_SHA"');
expect(exactIdentity.run).toContain(
".github/workflows/release-publication-eligibility.yml@${TOOLING_REF}",
);
expect(setup.with?.["cache-mode"]).toBe("restore");
expect(produce.run).toContain("scripts/release-plan-producer.mts");
expect(produce.run).toContain('--candidate-sha "$TARGET_SHA"');
expect(produce.run).toContain('--candidate-ref "$TARGET_CONTEXT_REF"');
expect(produce.run).toContain('--tooling-sha "$TOOLING_SHA"');
expect(produce.run).toContain('--tooling-full-ref "$TOOLING_REF"');
expect(planUpload.uses).toBe(UPLOAD_ARTIFACT_V7);
expect(planUpload.with?.["if-no-files-found"]).toBe("error");
expect(collect["continue-on-error"]).toBe(true);
expect(collect.run).toContain("scripts/release-publication-eligibility.mts");
expect(collect.run).toContain('--run-id "$GITHUB_RUN_ID"');
expect(collect.run).toContain('--run-attempt "$GITHUB_RUN_ATTEMPT"');
expect(collect.run).toContain("--job eligibility");
expect(collect.run).toContain(
'--artifact-id "${{ steps.release_plan_upload.outputs.artifact-id }}"',
);
expect(collect.run).toContain(
'--artifact-digest "${{ steps.release_plan_identity.outputs.digest }}"',
);
expect(upload.if).toBe("always()");
expect(upload.uses).toBe(UPLOAD_ARTIFACT_V7);
expect(upload.with?.["if-no-files-found"]).toBe("error");
expect(fail.if).toBe("always()");
expect(eligibilitySteps.indexOf(upload)).toBeLessThan(eligibilitySteps.indexOf(fail));
});
it("retains pending same-SHA QA calls in the shared concurrency group", () => {
const workflowPath = ".github/workflows/qa-live-transports-convex.yml";
const workflowSource = readFileSync(workflowPath, "utf8");
@@ -9,6 +9,7 @@ import {
parseReleasePublicationEligibilityReceiptJson,
RELEASE_PUBLICATION_ELIGIBILITY_CANONICALIZATION,
RELEASE_PUBLICATION_ELIGIBILITY_MAX_AGE_MS,
RELEASE_PUBLICATION_ELIGIBILITY_WORKFLOW_PATH,
verifyReleasePublicationEligibilityReceipt,
} from "../../scripts/release-publication-eligibility-contract.mjs";
@@ -17,7 +18,7 @@ const releasePlanLock = JSON.parse(
) as VerifiedReleasePlanLock;
const provenance = {
repository: releasePlanLock.plan.tooling.repository,
workflow_path: releasePlanLock.plan.tooling.workflow_path,
workflow_path: RELEASE_PUBLICATION_ELIGIBILITY_WORKFLOW_PATH,
workflow_ref: releasePlanLock.plan.tooling.ref,
workflow_sha: releasePlanLock.plan.tooling.sha,
run_id: "123456",
@@ -193,7 +194,22 @@ describe("release publication eligibility receipt contract", () => {
wrongToolingReceipt.provenance,
Date.parse("2026-08-21T00:00:03.000Z"),
),
).toThrow("ReleasePlan tooling");
).toThrow("producer and ReleasePlan tooling");
const wrongProducerReceipt = createReleasePublicationEligibilityReceipt({
...body,
provenance: {
...provenance,
workflow_path: releasePlanLock.plan.tooling.workflow_path,
},
});
expect(() =>
verifyReleasePublicationEligibilityReceipt(
wrongProducerReceipt,
releasePlanLock,
wrongProducerReceipt.provenance,
Date.parse("2026-08-21T00:00:03.000Z"),
),
).toThrow("producer and ReleasePlan tooling");
expect(() =>
createReleasePublicationEligibilityReceipt({
...body,
@@ -12,6 +12,7 @@ import {
parseReleasePublicationEligibilityReceiptJson,
RELEASE_PUBLICATION_CLAWHUB_REGISTRY,
RELEASE_PUBLICATION_ELIGIBILITY_MAX_AGE_MS,
RELEASE_PUBLICATION_ELIGIBILITY_WORKFLOW_PATH,
RELEASE_PUBLICATION_NPM_REGISTRY,
type ReleasePublicationEligibilityReceipt,
} from "../../scripts/release-publication-eligibility-contract.mjs";
@@ -38,7 +39,7 @@ function verified(lock: ReleasePlanLock): VerifiedReleasePlanLock {
function provenance(lock: ReleasePlanLock) {
return {
repository: lock.plan.tooling.repository,
workflow_path: lock.plan.tooling.workflow_path,
workflow_path: RELEASE_PUBLICATION_ELIGIBILITY_WORKFLOW_PATH,
workflow_ref: lock.plan.tooling.ref,
workflow_sha: lock.plan.tooling.sha,
run_id: "123456",