ci: make extended-stable closeout idempotent

This commit is contained in:
Dallin Romney
2026-08-08 16:14:30 +08:00
parent 5ce4cf8319
commit 883eb91053
6 changed files with 186 additions and 118 deletions
@@ -46,50 +46,8 @@ concurrency:
cancel-in-progress: false
jobs:
release_policy:
name: Classify release trigger
runs-on: ubuntu-latest
outputs:
should_run: ${{ steps.release.outputs.should_run }}
steps:
- name: Download trusted release classifier
if: ${{ github.event_name == 'release' }}
env:
GH_TOKEN: ${{ github.token }}
WORKFLOW_SHA: ${{ github.workflow_sha }}
run: |
set -euo pipefail
gh api "repos/${GITHUB_REPOSITORY}/contents/scripts/lib/release-version.mjs?ref=${WORKFLOW_SHA}" \
--jq .content | base64 --decode > "${RUNNER_TEMP}/release-version.mjs"
- name: Classify release track
id: release
env:
CLASSIFIER_FILE: ${{ runner.temp }}/release-version.mjs
EVENT_NAME: ${{ github.event_name }}
RELEASE_TAG: ${{ github.event.release.tag_name || '' }}
run: |
if [[ "${EVENT_NAME}" != "release" ]]; then
echo "should_run=true" >> "$GITHUB_OUTPUT"
exit 0
fi
node --input-type=module <<'NODE'
import { appendFileSync } from "node:fs";
import { pathToFileURL } from "node:url";
const { classifyReleaseTrain, parseReleaseVersion } = await import(
pathToFileURL(process.env.CLASSIFIER_FILE).href
);
const parsed = parseReleaseVersion(process.env.RELEASE_TAG.replace(/^v/u, ""));
const track = parsed === null ? "unknown" : classifyReleaseTrain(parsed);
appendFileSync(process.env.GITHUB_OUTPUT, `should_run=${track !== "extended-stable"}\n`);
NODE
resolve-base:
needs: release_policy
if: >-
needs.release_policy.outputs.should_run == 'true' &&
github.repository == 'openclaw/openclaw' &&
(github.event_name != 'workflow_dispatch' || github.ref == 'refs/heads/main')
runs-on: ubuntu-latest
@@ -12,36 +12,7 @@ jobs:
dispatch-translate:
runs-on: ubuntu-latest
steps:
- name: Download trusted release classifier
env:
GH_TOKEN: ${{ github.token }}
WORKFLOW_SHA: ${{ github.workflow_sha }}
run: |
set -euo pipefail
gh api "repos/${GITHUB_REPOSITORY}/contents/scripts/lib/release-version.mjs?ref=${WORKFLOW_SHA}" \
--jq .content | base64 --decode > "${RUNNER_TEMP}/release-version.mjs"
- name: Classify release track
id: release
env:
CLASSIFIER_FILE: ${{ runner.temp }}/release-version.mjs
RELEASE_TAG: ${{ github.event.release.tag_name }}
run: |
node --input-type=module <<'NODE'
import { appendFileSync } from "node:fs";
import { pathToFileURL } from "node:url";
const { classifyReleaseTrain, parseReleaseVersion } = await import(
pathToFileURL(process.env.CLASSIFIER_FILE).href
);
const parsed = parseReleaseVersion(process.env.RELEASE_TAG.replace(/^v/u, ""));
const track = parsed === null ? "unknown" : classifyReleaseTrain(parsed);
appendFileSync(process.env.GITHUB_OUTPUT, `track=${track}\n`);
NODE
- name: Trigger translation coordinator in publish repo
if: ${{ steps.release.outputs.track != 'extended-stable' }}
env:
GH_TOKEN: ${{ secrets.OPENCLAW_DOCS_SYNC_TOKEN }}
RELEASE_TAG: ${{ github.event.release.tag_name }}
+88 -26
View File
@@ -2389,6 +2389,7 @@ jobs:
outputs:
release_id: ${{ steps.release.outputs.release_id }}
release_body_sha256: ${{ steps.release.outputs.release_body_sha256 }}
release_already_public: ${{ steps.release.outputs.release_already_public }}
steps:
- name: Verify exact npm and selector readback matches preflight bytes
env:
@@ -2480,17 +2481,17 @@ jobs:
verify_release_resource() {
local release_id="$1"
local expected_draft="${2:-}"
local body_file="${RUNNER_TEMP}/extended-stable-existing-release.md"
local require_exact_body="${3:-false}"
local resource_file="${RUNNER_TEMP}/extended-stable-release.json"
gh api "repos/${GITHUB_REPOSITORY}/releases/${release_id}" > "${resource_file}"
jq -r '.body // ""' "${resource_file}" > "${body_file}"
RELEASE_BODY_FILE="${body_file}" \
RELEASE_BODY_FILE="${notes_file}" \
RELEASE_CHANGELOG_FILE="${GITHUB_WORKSPACE}/CHANGELOG.md" \
RELEASE_REPOSITORY="${GITHUB_REPOSITORY}" \
RELEASE_TAG="${RELEASE_TAG}" \
RELEASE_TITLE="${release_title}" \
RELEASE_RESOURCE_FILE="${resource_file}" \
EXPECTED_DRAFT="${expected_draft}" \
REQUIRE_EXACT_BODY="${require_exact_body}" \
node --input-type=module <<'NODE'
import { readFileSync } from "node:fs";
import {
@@ -2499,9 +2500,10 @@ jobs:
} from "./.release-harness/scripts/render-github-release-notes.mjs";
const release = JSON.parse(readFileSync(process.env.RELEASE_RESOURCE_FILE, "utf8"));
const body = readFileSync(process.env.RELEASE_BODY_FILE, "utf8");
const body = release.body ?? "";
const changelog = readFileSync(process.env.RELEASE_CHANGELOG_FILE, "utf8");
const expectedDraft = process.env.EXPECTED_DRAFT;
const expectedBody = readFileSync(process.env.RELEASE_BODY_FILE, "utf8");
const notes = verifyGithubReleaseNotes({
body,
changelog,
@@ -2514,6 +2516,9 @@ jobs:
release.name !== process.env.RELEASE_TITLE ||
release.prerelease !== false ||
(expectedDraft !== "" && release.draft !== (expectedDraft === "true")) ||
!Array.isArray(release.assets) ||
release.assets.length !== 0 ||
(process.env.REQUIRE_EXACT_BODY === "true" && body !== expectedBody) ||
!notes.matches
) {
console.error("Existing GitHub release does not match the canonical extended-stable contract.");
@@ -2522,6 +2527,23 @@ jobs:
NODE
}
wait_until_not_latest() {
local attempt latest_tag
for attempt in $(seq 1 12); do
latest_tag="$(
gh api "repos/${GITHUB_REPOSITORY}/releases/latest" --jq '.tag_name' 2>/dev/null || true
)"
if [[ -n "${latest_tag}" && "${latest_tag}" != "${RELEASE_TAG}" ]]; then
return 0
fi
if [[ "${attempt}" != "12" ]]; then
sleep 5
fi
done
echo "Extended-stable release ${RELEASE_TAG} must not be GitHub Latest." >&2
return 1
}
verify_release_tag_target
cat > "${verification_file}" <<EOF
### Release verification
@@ -2538,20 +2560,10 @@ jobs:
--verification-file "${verification_file}" \
--output "${notes_file}"
release_already_public=false
release_id="$(resolve_release_id)"
if [[ -n "${release_id}" ]]; then
verify_release_resource "${release_id}"
release_draft="$(gh api "repos/${GITHUB_REPOSITORY}/releases/${release_id}" --jq '.draft')"
if [[ "${release_draft}" == "true" ]]; then
gh release edit "${RELEASE_TAG}" --repo "${GITHUB_REPOSITORY}" \
--title "${release_title}" \
--notes-file "${notes_file}" \
--prerelease=false \
--latest=false
verify_release_resource "${release_id}" true
else
echo "Existing canonical public release will be revalidated after Docker publication."
fi
else
if ! gh release create "${RELEASE_TAG}" --repo "${GITHUB_REPOSITORY}" \
--verify-tag \
@@ -2568,20 +2580,39 @@ jobs:
}
verify_release_resource "${release_id}"
fi
release_draft="$(gh api "repos/${GITHUB_REPOSITORY}/releases/${release_id}" --jq '.draft')"
if [[ "${release_draft}" == "true" ]]; then
gh release edit "${RELEASE_TAG}" --repo "${GITHUB_REPOSITORY}" \
--title "${release_title}" \
--notes-file "${notes_file}" \
--prerelease=false \
--latest=false
verify_release_resource "${release_id}" true true
elif [[ "${release_draft}" == "false" ]]; then
verify_release_resource "${release_id}" false true
wait_until_not_latest
release_already_public=true
echo "Existing canonical public release is already complete; Docker will not be rebuilt."
else
echo "GitHub release ${release_id} has an invalid draft state." >&2
exit 1
fi
release_body_sha256="$(
gh api "repos/${GITHUB_REPOSITORY}/releases/${release_id}" |
jq -j '.body // ""' |
sha256sum |
awk '{print $1}'
sha256sum "${notes_file}" | awk '{print $1}'
)"
echo "release_id=${release_id}" >> "$GITHUB_OUTPUT"
echo "release_body_sha256=${release_body_sha256}" >> "$GITHUB_OUTPUT"
echo "- Extended-stable GitHub release draft: https://github.com/${GITHUB_REPOSITORY}/releases/tag/${RELEASE_TAG}" >> "$GITHUB_STEP_SUMMARY"
echo "release_already_public=${release_already_public}" >> "$GITHUB_OUTPUT"
if [[ "${release_already_public}" == "true" ]]; then
echo "- Extended-stable GitHub release: already public; Docker rebuild skipped" >> "$GITHUB_STEP_SUMMARY"
else
echo "- Extended-stable GitHub release draft: https://github.com/${GITHUB_REPOSITORY}/releases/tag/${RELEASE_TAG}" >> "$GITHUB_STEP_SUMMARY"
fi
publish_docker:
name: Publish Docker images
needs: [resolve_release_target, publish, prepare_extended_stable_release]
if: ${{ always() && ((inputs.publish_openclaw_npm && needs.publish.result == 'success') || (inputs.publish_docker_only && needs.prepare_extended_stable_release.result == 'success')) }}
if: ${{ always() && ((inputs.publish_openclaw_npm && needs.publish.result == 'success') || (inputs.publish_docker_only && needs.prepare_extended_stable_release.result == 'success' && needs.prepare_extended_stable_release.outputs.release_already_public != 'true')) }}
uses: ./.github/workflows/docker-release.yml
with:
tag: ${{ inputs.tag }}
@@ -2669,6 +2700,8 @@ jobs:
release.name !== process.env.RELEASE_TITLE ||
release.prerelease !== false ||
release.draft !== (process.env.EXPECTED_DRAFT === "true") ||
!Array.isArray(release.assets) ||
release.assets.length !== 0 ||
bodySha256 !== process.env.EXPECTED_BODY_SHA256
) {
console.error("GitHub release state does not match the extended-stable contract.");
@@ -2677,9 +2710,30 @@ jobs:
NODE
}
verify_release_tag_target() {
local direct_sha peeled_sha remote_refs remote_sha
remote_refs="$(git ls-remote --tags "https://github.com/${GITHUB_REPOSITORY}.git" \
"refs/tags/${RELEASE_TAG}" \
"refs/tags/${RELEASE_TAG}^{}")"
direct_sha="$(printf '%s\n' "${remote_refs}" |
awk -v ref="refs/tags/${RELEASE_TAG}" '$2 == ref { print $1 }')"
peeled_sha="$(printf '%s\n' "${remote_refs}" |
awk -v ref="refs/tags/${RELEASE_TAG}^{}" '$2 == ref { print $1 }')"
remote_sha="${peeled_sha:-${direct_sha}}"
if [[ -z "${remote_sha}" || "${remote_sha}" != "${TARGET_SHA}" ]]; then
echo "Release tag ${RELEASE_TAG} must still resolve to ${TARGET_SHA}; found ${remote_sha:-<missing>}." >&2
exit 1
fi
}
# Docker publication can take long enough for the tag to change after
# preparation. Recheck immediately before accepting or publishing the page.
verify_release_tag_target
current_draft="$(gh api "repos/${GITHUB_REPOSITORY}/releases/${RELEASE_ID}" --jq '.draft')"
if [[ "${current_draft}" == "true" ]]; then
verify_release_state true
# GITHUB_TOKEN publication intentionally does not fan out release.published
# workflows; extended-stable scope stays owned by this closeout.
gh api --method PATCH "repos/${GITHUB_REPOSITORY}/releases/${RELEASE_ID}" \
-F draft=false \
-F prerelease=false \
@@ -2699,9 +2753,17 @@ jobs:
fi
sleep 5
done
latest_tag="$(gh api "repos/${GITHUB_REPOSITORY}/releases/latest" --jq '.tag_name')"
if [[ "${latest_tag}" == "${RELEASE_TAG}" ]]; then
echo "Extended-stable release ${RELEASE_TAG} must not become GitHub Latest." >&2
exit 1
fi
for attempt in $(seq 1 12); do
latest_tag="$(
gh api "repos/${GITHUB_REPOSITORY}/releases/latest" --jq '.tag_name' 2>/dev/null || true
)"
if [[ -n "${latest_tag}" && "${latest_tag}" != "${RELEASE_TAG}" ]]; then
break
fi
if [[ "${attempt}" == "12" ]]; then
echo "Extended-stable release ${RELEASE_TAG} must not become GitHub Latest." >&2
exit 1
fi
sleep 5
done
echo "- Extended-stable GitHub release: https://github.com/${GITHUB_REPOSITORY}/releases/tag/${RELEASE_TAG}" >> "$GITHUB_STEP_SUMMARY"
+1 -19
View File
@@ -41,7 +41,6 @@ const MANTIS_GITHUB_APP_CLIENT_ID = "Iv23liPJCozR0uHm6P7G";
const OPENGREP_PR_DIFF_WORKFLOW = ".github/workflows/opengrep-precise.yml";
const OPENGREP_FULL_WORKFLOW = ".github/workflows/opengrep-precise-full.yml";
const CONTROL_UI_LOCALE_REFRESH_WORKFLOW = ".github/workflows/control-ui-locale-refresh.yml";
const DOCS_TRANSLATE_RELEASE_WORKFLOW = ".github/workflows/docs-translate-trigger-release.yml";
const NATIVE_APP_LOCALE_REFRESH_WORKFLOW = ".github/workflows/native-app-locale-refresh.yml";
const CREATE_GENERATED_PR_TOKENS_ACTION = ".github/actions/create-generated-pr-tokens/action.yml";
const PUBLISH_GENERATED_PR_ACTION = ".github/actions/publish-generated-pr/action.yml";
@@ -1635,9 +1634,7 @@ NODE
it("keeps locale refresh matrices alive and publishes each aggregate through a PR", () => {
const controlUiWorkflow = parse(readFileSync(CONTROL_UI_LOCALE_REFRESH_WORKFLOW, "utf8"));
const docsTranslateWorkflow = parse(readFileSync(DOCS_TRANSLATE_RELEASE_WORKFLOW, "utf8"));
const workflow = parse(readFileSync(NATIVE_APP_LOCALE_REFRESH_WORKFLOW, "utf8"));
const controlUiReleasePolicy = controlUiWorkflow.jobs.release_policy;
const controlUiResolveBase = controlUiWorkflow.jobs["resolve-base"];
const nativeResolveBase = workflow.jobs["resolve-base"];
const controlUiPreflight = controlUiWorkflow.jobs["publisher-preflight"];
@@ -1691,23 +1688,8 @@ NODE
expect(nativeResolveBase.if).not.toContain("chore(i18n): refresh native locales");
const controlResolveCondition = controlUiResolveBase.if.replace(/\s+/gu, " ");
expect(controlResolveCondition).toBe(
"needs.release_policy.outputs.should_run == 'true' && github.repository == 'openclaw/openclaw' && (github.event_name != 'workflow_dispatch' || github.ref == 'refs/heads/main')",
"github.repository == 'openclaw/openclaw' && (github.event_name != 'workflow_dispatch' || github.ref == 'refs/heads/main')",
);
expect(controlUiResolveBase.needs).toBe("release_policy");
expect(controlUiReleasePolicy.outputs.should_run).toBe(
"${{ steps.release.outputs.should_run }}",
);
expect(
controlUiReleasePolicy.steps.find(
(step: WorkflowStep) => step.name === "Classify release track",
).run,
).toContain('track !== "extended-stable"');
const docsDispatch = docsTranslateWorkflow.jobs["dispatch-translate"];
expect(
docsDispatch.steps.find(
(step: WorkflowStep) => step.name === "Trigger translation coordinator in publish repo",
).if,
).toBe("${{ steps.release.outputs.track != 'extended-stable' }}");
expect(controlResolveCondition).not.toContain("inputs.token_preflight_only");
expect(controlResolveCondition).not.toContain("github.ref_type");
expect(nativeResolveBase.if).toBe(
@@ -5363,6 +5363,9 @@ describe("package artifact reuse", () => {
expect(extendedPrepareJob.needs).toEqual(["resolve_release_target"]);
expect(extendedPrepare.run).toContain("verify_release_tag_target");
expect(extendedPrepare.run).toContain("verifyGithubReleaseNotes");
expect(extendedPrepare.run).toContain("body !== expectedBody");
expect(extendedPrepare.run).toContain("release.assets.length !== 0");
expect(extendedPrepare.run).toContain("release_already_public=true");
expect(extendedPrepare.run).toContain("--draft");
expect(extendedPrepare.run).toContain("--latest=false");
expect(extendedFinalizeJob.needs).toEqual([
@@ -5373,6 +5376,14 @@ describe("package artifact reuse", () => {
expect(extendedFinalizeJob.if).toContain("needs.publish_docker.result == 'success'");
expect(extendedFinalize.run).toContain("-f make_latest=false");
expect(extendedFinalize.run).toContain("EXPECTED_BODY_SHA256");
expect(extendedFinalize.run).toContain("release.assets.length !== 0");
expect(extendedFinalize.run).toContain("for attempt in $(seq 1 12)");
expect(extendedFinalize.run).toContain(
'git ls-remote --tags "https://github.com/${GITHUB_REPOSITORY}.git"',
);
expect(extendedFinalize.run.indexOf("verify_release_tag_target\n")).toBeLessThan(
extendedFinalize.run.indexOf('current_draft="$(gh api'),
);
});
it("accepts tag-matched frozen release branches in OpenClaw npm preflight", () => {
+86 -2
View File
@@ -187,6 +187,20 @@ function step(workflowJob: WorkflowJob, name: string): WorkflowStep {
return value;
}
function shellFunctionSource(source: string, functionName: string): string {
const startMarker = `${functionName}() {`;
const endMarker = "\n}";
const start = source.indexOf(startMarker);
if (start < 0) {
throw new Error(`missing shell function ${functionName}`);
}
const end = source.indexOf(endMarker, start);
if (end < 0) {
throw new Error(`missing shell function terminator ${functionName}`);
}
return source.slice(start, end + endMarker.length);
}
function expectReadOnlyPackagePermission(workflowJob: WorkflowJob): void {
expect(permissionAt(workflowJob.permissions, "packages", "none")).toBe("read");
}
@@ -1028,6 +1042,9 @@ describe("release validation no-push transport", () => {
]);
expect(dockerCall.if).toContain("needs.publish.result == 'success'");
expect(dockerCall.if).toContain("needs.prepare_extended_stable_release.result == 'success'");
expect(dockerCall.if).toContain(
"needs.prepare_extended_stable_release.outputs.release_already_public != 'true'",
);
expect(dockerCall.with).toEqual({
tag: "${{ inputs.tag }}",
release_sha: "${{ needs.resolve_release_target.outputs.sha }}",
@@ -1072,6 +1089,7 @@ describe("release validation no-push transport", () => {
expect(prepareRelease.outputs).toEqual({
release_id: "${{ steps.release.outputs.release_id }}",
release_body_sha256: "${{ steps.release.outputs.release_body_sha256 }}",
release_already_public: "${{ steps.release.outputs.release_already_public }}",
});
expect(verifyNpm.run).toContain('npm view "openclaw@${version}" version');
expect(verifyNpm.run).toContain("Published npm tarball does not match");
@@ -1082,9 +1100,14 @@ describe("release validation no-push transport", () => {
expect(createDraft.run).toContain("--draft");
expect(createDraft.run).toContain("--prerelease=false");
expect(createDraft.run).toContain("--latest=false");
expect(createDraft.run).toContain("Existing canonical public release will be revalidated");
expect(createDraft.run).toContain("body !== expectedBody");
expect(createDraft.run).toContain("release.assets.length !== 0");
expect(createDraft.run).toContain('verify_release_resource "${release_id}" false true');
expect(createDraft.run).toContain("wait_until_not_latest");
expect(createDraft.run).toContain("release_already_public=true");
expect(createDraft.run).toContain("Docker will not be rebuilt");
expect(createDraft.run).toContain("wait_for_release_id");
expect(createDraft.run).toContain("release_body_sha256=");
expect(createDraft.run).toContain('sha256sum "${notes_file}"');
expect(finalizeRelease.needs).toEqual([
"resolve_release_target",
@@ -1108,8 +1131,19 @@ describe("release validation no-push transport", () => {
expect(publishDraft.run).toContain("-F prerelease=false");
expect(publishDraft.run).toContain("-f make_latest=false");
expect(publishDraft.run).toContain("EXPECTED_BODY_SHA256");
expect(publishDraft.run).toContain("release.assets.length !== 0");
expect(publishDraft.run).toContain("releases/latest");
expect(publishDraft.run).toContain("must not become GitHub Latest");
expect(publishDraft.run).toContain("for attempt in $(seq 1 12)");
expect(publishDraft.run).toContain(
"GITHUB_TOKEN publication intentionally does not fan out release.published",
);
expect(publishDraft.run).toContain(
'git ls-remote --tags "https://github.com/${GITHUB_REPOSITORY}.git"',
);
expect(publishDraft.run.indexOf("verify_release_tag_target\n")).toBeLessThan(
publishDraft.run.indexOf('current_draft="$(gh api'),
);
const identity = step(
job(dockerRelease, "validate_release_identity"),
@@ -1121,6 +1155,56 @@ describe("release validation no-push transport", () => {
expect(identity.run).toContain("^v${package_version}-[1-9][0-9]*$");
});
it("rechecks lightweight and annotated extended-stable tags before finalization", () => {
const releasePublish = readWorkflow(".github/workflows/openclaw-release-publish.yml");
const publishDraft = step(
job(releasePublish, "finalize_extended_stable_github_release"),
"Publish the verified extended-stable draft",
);
const verifyTag = shellFunctionSource(publishDraft.run ?? "", "verify_release_tag_target");
const targetSha = "a".repeat(40);
const tagObjectSha = "b".repeat(40);
const runVerify = (remoteRefs: string) =>
spawnSync(
"bash",
[
"-c",
`
set -euo pipefail
GITHUB_REPOSITORY=openclaw/openclaw
RELEASE_TAG=v2026.6.35
TARGET_SHA=${targetSha}
git() {
printf '%s' "\${REMOTE_REFS}"
}
${verifyTag}
verify_release_tag_target
`,
],
{
encoding: "utf8",
env: { ...process.env, REMOTE_REFS: remoteRefs },
},
);
const lightweight = runVerify(`${targetSha}\trefs/tags/v2026.6.35\n`);
expect(lightweight.status, lightweight.stderr).toBe(0);
const annotated = runVerify(
`${tagObjectSha}\trefs/tags/v2026.6.35\n${targetSha}\trefs/tags/v2026.6.35^{}\n`,
);
expect(annotated.status, annotated.stderr).toBe(0);
const moved = runVerify(`${"c".repeat(40)}\trefs/tags/v2026.6.35\n`);
expect(moved.status).toBe(1);
expect(moved.stderr).toContain(`must still resolve to ${targetSha}`);
const missing = runVerify("");
expect(missing.status).toBe(1);
expect(missing.stderr).toContain("found <missing>");
});
it("fails a missing required local live image before any registry pull", () => {
const root = mkdtempSync(join(tmpdir(), "openclaw-live-local-image-"));
const bin = join(root, "bin");