Files

3448 lines
150 KiB
YAML

name: Release
run-name: ${{ github.event_name == 'workflow_dispatch' && inputs.release_operation != 'none' && format('Release {0} {1} ({2})', inputs.release_operation, inputs.release_channel, inputs.release_bump) || github.event_name == 'workflow_dispatch' && inputs.governance_preflight_nonce != '' && format('Release governance preflight {0}', inputs.governance_preflight_nonce) || github.event_name == 'workflow_dispatch' && inputs.recover_release_version != '' && format('Release recovery {0} at {1} {2}', inputs.recover_release_version, inputs.recover_release_commit, inputs.recover_release_nonce) || github.event_name == 'workflow_dispatch' && inputs.repair_npm_version != '' && format('Release npm repair {0}', inputs.repair_npm_version) || github.event_name == 'workflow_dispatch' && inputs.repair_gitee_version != '' && format('Release Gitee repair {0}', inputs.repair_gitee_version) || github.event_name == 'workflow_dispatch' && inputs.repair_oss_version != '' && format('Release OSS repair {0}', inputs.repair_oss_version) || github.workflow }}
on:
workflow_dispatch:
inputs:
release_operation:
description: "Plan the next version or publish it from the current default-branch commit"
required: true
default: none
type: choice
options:
- none
- plan
- publish
release_channel:
description: "Release channel (used only when release_operation is plan/publish)"
required: true
default: beta
type: choice
options:
- beta
- stable
release_bump:
description: "New beta core bump; ignored while continuing an open beta line"
required: true
default: patch
type: choice
options:
- patch
- minor
- major
repair_npm_version:
description: "Recover npm for a public immutable release without moving latest/beta"
required: false
type: string
repair_gitee_version:
description: "Mirror an existing public immutable release's assets to Gitee"
required: false
type: string
repair_oss_version:
description: "Mirror an existing public immutable release's assets to OSS"
required: false
type: string
governance_preflight_commit:
description: "Verify release governance for this exact default-branch commit without publishing"
required: false
type: string
governance_preflight_nonce:
description: "Unique identifier for one release governance preflight"
required: false
type: string
recover_release_version:
description: "Recover one existing failed release tag (vX.Y.Z or vX.Y.Z-beta.N)"
required: false
type: string
recover_release_tag_object:
description: "Expected 40-character annotated tag object SHA"
required: false
type: string
recover_release_commit:
description: "Expected 40-character peeled release commit SHA"
required: false
type: string
recover_failed_run_id:
description: "Failed exact-tag Release workflow run ID"
required: false
type: string
recover_failed_run_attempt:
description: "Failed attempt number within the exact-tag Release workflow run"
required: false
type: string
recover_release_nonce:
description: "Unique identifier for this machine-verified recovery request"
required: false
type: string
recover_release_confirmation:
description: "Type the exact release version again to authorize recovery"
required: false
type: string
permissions:
contents: read
# Queue governance checks, tag releases, protected recoveries, and channel
# repairs behind one publication lock. The local entrypoint also refuses to tag
# while a Release run is active.
concurrency:
group: dws-release-publication
queue: max
jobs:
dispatch-contract:
if: ${{ github.event_name == 'workflow_dispatch' }}
runs-on: ubuntu-latest
timeout-minutes: 2
outputs:
mode: ${{ steps.mode.outputs.mode }}
steps:
- name: Select exactly one dispatch mode
id: mode
env:
REPAIR_NPM_VERSION: ${{ inputs.repair_npm_version }}
REPAIR_GITEE_VERSION: ${{ inputs.repair_gitee_version }}
REPAIR_OSS_VERSION: ${{ inputs.repair_oss_version }}
GOVERNANCE_COMMIT: ${{ inputs.governance_preflight_commit }}
GOVERNANCE_NONCE: ${{ inputs.governance_preflight_nonce }}
RECOVER_VERSION: ${{ inputs.recover_release_version }}
RECOVER_TAG_OBJECT: ${{ inputs.recover_release_tag_object }}
RECOVER_COMMIT: ${{ inputs.recover_release_commit }}
RECOVER_FAILED_RUN_ID: ${{ inputs.recover_failed_run_id }}
RECOVER_FAILED_RUN_ATTEMPT: ${{ inputs.recover_failed_run_attempt }}
RECOVER_NONCE: ${{ inputs.recover_release_nonce }}
RECOVER_CONFIRMATION: ${{ inputs.recover_release_confirmation }}
RELEASE_OPERATION: ${{ inputs.release_operation }}
RELEASE_CHANNEL: ${{ inputs.release_channel }}
RELEASE_BUMP: ${{ inputs.release_bump }}
run: |
set -eu
release_flow=0
npm_repair=0
gitee_repair=0
oss_repair=0
governance=0
recovery=0
if test -n "$RELEASE_OPERATION" && test "$RELEASE_OPERATION" != none; then
release_flow=1
fi
test -z "$REPAIR_NPM_VERSION" || npm_repair=1
test -z "$REPAIR_GITEE_VERSION" || gitee_repair=1
test -z "$REPAIR_OSS_VERSION" || oss_repair=1
if test -n "$GOVERNANCE_COMMIT" || test -n "$GOVERNANCE_NONCE"; then governance=1; fi
if test -n "$RECOVER_VERSION" || test -n "$RECOVER_TAG_OBJECT" || \
test -n "$RECOVER_COMMIT" || test -n "$RECOVER_FAILED_RUN_ID" || \
test -n "$RECOVER_FAILED_RUN_ATTEMPT" || \
test -n "$RECOVER_NONCE" || \
test -n "$RECOVER_CONFIRMATION"; then recovery=1; fi
test $((release_flow + npm_repair + gitee_repair + oss_repair + governance + recovery)) -eq 1 || {
echo "workflow_dispatch must select exactly one release mode" >&2
exit 1
}
if test "$release_flow" -eq 1; then
case "$RELEASE_CHANNEL" in beta|stable) ;; *)
echo "release_channel must be beta or stable" >&2
exit 1
esac
case "$RELEASE_BUMP" in patch|minor|major) ;; *)
echo "release_bump must be patch, minor, or major" >&2
exit 1
esac
if test "$RELEASE_OPERATION" = plan; then
echo "mode=plan_release" >> "$GITHUB_OUTPUT"
elif test "$RELEASE_OPERATION" = publish; then
echo "mode=create_release" >> "$GITHUB_OUTPUT"
else
echo "release_operation must be none, plan, or publish" >&2
exit 1
fi
elif test "$npm_repair" -eq 1; then
echo "mode=repair_npm" >> "$GITHUB_OUTPUT"
elif test "$gitee_repair" -eq 1; then
echo "mode=repair_gitee" >> "$GITHUB_OUTPUT"
elif test "$oss_repair" -eq 1; then
echo "mode=repair_oss" >> "$GITHUB_OUTPUT"
elif test "$governance" -eq 1; then
if test -z "$GOVERNANCE_COMMIT" || test -z "$GOVERNANCE_NONCE"; then
echo "governance preflight requires both commit and nonce" >&2
exit 1
fi
echo "mode=governance_preflight" >> "$GITHUB_OUTPUT"
else
if test -z "$RECOVER_VERSION" || test -z "$RECOVER_TAG_OBJECT" || \
test -z "$RECOVER_COMMIT" || test -z "$RECOVER_FAILED_RUN_ID" || \
test -z "$RECOVER_FAILED_RUN_ATTEMPT" || \
test -z "$RECOVER_NONCE" || test -z "$RECOVER_CONFIRMATION"; then
echo "release recovery requires every recovery input" >&2
exit 1
fi
test "$RECOVER_CONFIRMATION" = "$RECOVER_VERSION" || {
echo "release recovery confirmation must equal the exact version" >&2
exit 1
}
printf '%s\n' "$RECOVER_COMMIT" | grep -Eq '^[0-9a-f]{40}$' || {
echo "recover_release_commit must be a full lowercase commit SHA" >&2
exit 1
}
printf '%s\n' "$RECOVER_TAG_OBJECT" | grep -Eq '^[0-9a-f]{40}$' || {
echo "recover_release_tag_object must be a full lowercase tag object SHA" >&2
exit 1
}
printf '%s\n' "$RECOVER_FAILED_RUN_ID" | grep -Eq '^[1-9][0-9]*$' || {
echo "recover_failed_run_id must be a positive run ID" >&2
exit 1
}
printf '%s\n' "$RECOVER_FAILED_RUN_ATTEMPT" | grep -Eq '^[1-9][0-9]*$' || {
echo "recover_failed_run_attempt must be a positive attempt number" >&2
exit 1
}
printf '%s\n' "$RECOVER_NONCE" | grep -Eq "^${RECOVER_COMMIT}-[0-9]+-[0-9]+$" || {
echo "recover_release_nonce must be bound to the release commit" >&2
exit 1
}
echo "mode=recover_release" >> "$GITHUB_OUTPUT"
fi
- name: Authorize repository release requester
if: ${{ steps.mode.outputs.mode == 'plan_release' || steps.mode.outputs.mode == 'create_release' }}
uses: actions/github-script@v7
env:
EXPECTED_REPOSITORY: DingTalk-Real-AI/dingtalk-workspace-cli
REQUESTED_BY: ${{ github.actor }}
TRIGGERED_BY: ${{ github.triggering_actor }}
with:
script: |
const expectedRepository = process.env.EXPECTED_REPOSITORY;
if (context.payload.repository.full_name !== expectedRepository) {
core.setFailed(`release requests are restricted to ${expectedRepository}`);
return;
}
const actors = new Set([
process.env.REQUESTED_BY,
process.env.TRIGGERED_BY,
]);
for (const username of actors) {
if (!/^[A-Za-z0-9](?:[A-Za-z0-9-]{0,38})$/.test(username || "")) {
core.setFailed("release request contains an invalid GitHub actor");
return;
}
let permission;
try {
const response = await github.rest.repos.getCollaboratorPermissionLevel({
...context.repo,
username,
});
permission = response.data.permission;
} catch (error) {
if (error.status === 404) {
core.setFailed(`${username} is not an authorized repository collaborator`);
return;
}
throw error;
}
// GitHub maps maintain to the legacy write permission here.
if (!["write", "admin"].includes(permission)) {
core.setFailed(
`${username} needs repository write, maintain, or admin access to release`,
);
return;
}
}
governance-preflight:
name: Release governance preflight
needs: dispatch-contract
if: ${{ needs.dispatch-contract.outputs.mode == 'governance_preflight' || needs.dispatch-contract.outputs.mode == 'create_release' }}
runs-on: ubuntu-latest
timeout-minutes: 5
permissions:
checks: read
contents: read
steps:
- name: Validate trusted governance preflight context
env:
DEFAULT_BRANCH: ${{ github.event.repository.default_branch }}
EXPECTED_REPOSITORY: DingTalk-Real-AI/dingtalk-workspace-cli
DISPATCH_MODE: ${{ needs.dispatch-contract.outputs.mode }}
PREFLIGHT_COMMIT: ${{ needs.dispatch-contract.outputs.mode == 'create_release' && github.sha || inputs.governance_preflight_commit }}
PREFLIGHT_NONCE: ${{ inputs.governance_preflight_nonce }}
REPAIR_NPM_VERSION: ${{ inputs.repair_npm_version }}
run: |
set -eu
test "$GITHUB_REPOSITORY" = "$EXPECTED_REPOSITORY" || {
echo "governance preflight is restricted to $EXPECTED_REPOSITORY" >&2
exit 1
}
test "$GITHUB_REF_NAME" = "$DEFAULT_BRANCH" || {
echo "governance preflight must run from the default branch $DEFAULT_BRANCH" >&2
exit 1
}
test -z "$REPAIR_NPM_VERSION" || {
echo "governance preflight cannot be combined with npm repair" >&2
exit 1
}
case "$DISPATCH_MODE" in
governance_preflight)
if test -z "$PREFLIGHT_COMMIT" || test -z "$PREFLIGHT_NONCE"; then
echo "governance_preflight_commit and governance_preflight_nonce are both required" >&2
exit 1
fi
;;
create_release)
test -z "$PREFLIGHT_NONCE" || {
echo "cloud release cannot be combined with a governance nonce" >&2
exit 1
}
;;
*)
echo "unsupported governance preflight mode: $DISPATCH_MODE" >&2
exit 1
;;
esac
case "$PREFLIGHT_COMMIT" in
*[!0-9a-f]*)
echo "governance_preflight_commit must be a lowercase commit SHA" >&2
exit 1
;;
esac
test "${#PREFLIGHT_COMMIT}" -eq 40 || {
echo "governance_preflight_commit must be a full 40-character commit SHA" >&2
exit 1
}
test "$PREFLIGHT_COMMIT" = "$GITHUB_SHA" || {
echo "governance preflight commit must equal the dispatched default-branch commit" >&2
exit 1
}
- name: Require successful Code Admission contexts on the preflight commit
uses: actions/github-script@v7
env:
PREFLIGHT_COMMIT: ${{ needs.dispatch-contract.outputs.mode == 'create_release' && github.sha || inputs.governance_preflight_commit }}
with:
script: |
const { owner, repo } = context.repo;
const sha = process.env.PREFLIGHT_COMMIT;
const requiredContexts = [
"Lint",
"Test",
"Coverage",
"Policy",
"Edition",
"Interface Integrity",
"AI Behavior",
"CLI Smoke",
"Mock MCP",
];
const runs = await github.paginate(github.rest.checks.listForRef, {
owner,
repo,
ref: sha,
filter: "latest",
per_page: 100,
});
const latestByName = new Map();
for (const run of runs) {
if (run.head_sha !== sha || !requiredContexts.includes(run.name)) {
continue;
}
const current = latestByName.get(run.name);
if (!current || run.id > current.id) {
latestByName.set(run.name, run);
}
}
const missing = requiredContexts.filter((name) => !latestByName.has(name));
const nonSuccess = requiredContexts.flatMap((name) => {
const run = latestByName.get(name);
if (!run || run.conclusion === "success") {
return [];
}
return [`${name}=${run.conclusion || run.status || "unknown"}`];
});
if (missing.length > 0 || nonSuccess.length > 0) {
core.setFailed(
`Code Admission contexts are not all successful for ${sha}; ` +
`missing: ${missing.length > 0 ? missing.join(", ") : "none"}; ` +
`non-success: ${nonSuccess.length > 0 ? nonSuccess.join(", ") : "none"}`,
);
}
- name: Check out trusted preflight tooling
uses: actions/checkout@v4
with:
ref: ${{ needs.dispatch-contract.outputs.mode == 'create_release' && github.sha || inputs.governance_preflight_commit }}
persist-credentials: false
fetch-depth: 0
- name: Require release governance token
env:
RELEASE_GOVERNANCE_TOKEN: ${{ secrets.RELEASE_GOVERNANCE_TOKEN }}
run: |
test -n "$RELEASE_GOVERNANCE_TOKEN" || {
echo "RELEASE_GOVERNANCE_TOKEN with repository Administration read permission is required" >&2
exit 1
}
- name: Require immutable releases governance
uses: actions/github-script@v7
with:
# GITHUB_TOKEN cannot read repository administration settings. This
# dedicated credential needs only repository Administration: read.
github-token: ${{ secrets.RELEASE_GOVERNANCE_TOKEN }}
script: |
const { owner, repo } = context.repo;
const response = await github.request(
"GET /repos/{owner}/{repo}/immutable-releases",
{
owner,
repo,
headers: { "X-GitHub-Api-Version": "2026-03-10" },
},
);
if (response.data.enabled !== true) {
core.setFailed("Immutable releases must be enabled before publishing");
}
release-contract:
needs: [dispatch-contract, governance-preflight, release-plan, seal-release]
if: ${{ !cancelled() && (github.event_name == 'push' || (needs.dispatch-contract.result == 'success' && needs.dispatch-contract.outputs.mode == 'recover_release') || (needs.dispatch-contract.result == 'success' && needs.dispatch-contract.outputs.mode == 'create_release' && needs.governance-preflight.result == 'success' && needs.release-plan.result == 'success' && needs.seal-release.result == 'success')) }}
runs-on: ubuntu-latest
timeout-minutes: 20
permissions:
actions: read
checks: read
contents: read
outputs:
release_version: ${{ steps.target.outputs.release_version }}
release_commit: ${{ steps.target.outputs.release_commit }}
release_tag_object: ${{ steps.target.outputs.release_tag_object }}
oss_mirror: ${{ steps.target.outputs.oss_mirror }}
is_recovery: ${{ steps.target.outputs.is_recovery }}
failed_run_id: ${{ steps.target.outputs.failed_run_id }}
channel: ${{ steps.metadata.outputs.channel }}
previous_stable: ${{ steps.metadata.outputs.previous_stable }}
previous_stable_commit: ${{ steps.metadata.outputs.previous_stable_commit }}
from_beta: ${{ steps.metadata.outputs.from_beta }}
from_beta_commit: ${{ steps.metadata.outputs.from_beta_commit }}
steps:
- name: Resolve and verify exact release target
id: target
uses: actions/github-script@v7
env:
RECOVER_VERSION: ${{ inputs.recover_release_version }}
RECOVER_TAG_OBJECT: ${{ inputs.recover_release_tag_object }}
RECOVER_COMMIT: ${{ inputs.recover_release_commit }}
RECOVER_FAILED_RUN_ID: ${{ inputs.recover_failed_run_id }}
RECOVER_FAILED_RUN_ATTEMPT: ${{ inputs.recover_failed_run_attempt }}
DISPATCH_MODE: ${{ needs.dispatch-contract.outputs.mode }}
SEALED_VERSION: ${{ needs.seal-release.outputs.release_version }}
SEALED_COMMIT: ${{ needs.seal-release.outputs.release_commit }}
SEALED_TAG_OBJECT: ${{ needs.seal-release.outputs.release_tag_object }}
with:
script: |
const { owner, repo } = context.repo;
const dispatchMode = process.env.DISPATCH_MODE;
const isRecovery =
context.eventName === "workflow_dispatch" && dispatchMode === "recover_release";
const isCreate =
context.eventName === "workflow_dispatch" && dispatchMode === "create_release";
let version = context.ref.replace("refs/tags/", "");
let commit = context.sha;
let expectedTagObject = "";
let failedRunId = "";
let failedRunAttempt = "";
if (context.eventName === "workflow_dispatch" && !isRecovery && !isCreate) {
core.setFailed(`unsupported publication dispatch mode: ${dispatchMode}`);
return;
}
if (isRecovery || isCreate) {
if (
context.payload.repository.full_name !== "DingTalk-Real-AI/dingtalk-workspace-cli" ||
context.ref !== `refs/heads/${context.payload.repository.default_branch}`
) {
core.setFailed("release recovery must be dispatched from the official default branch");
return;
}
version = isCreate ? process.env.SEALED_VERSION : process.env.RECOVER_VERSION;
commit = isCreate ? process.env.SEALED_COMMIT : process.env.RECOVER_COMMIT;
expectedTagObject = isCreate
? process.env.SEALED_TAG_OBJECT
: process.env.RECOVER_TAG_OBJECT;
if (isRecovery) {
failedRunId = process.env.RECOVER_FAILED_RUN_ID;
failedRunAttempt = process.env.RECOVER_FAILED_RUN_ATTEMPT;
}
if (!/^v(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)(?:-beta\.[1-9]\d*)?$/.test(version)) {
core.setFailed(`invalid dispatched release version: ${version}`);
return;
}
if (!/^[0-9a-f]{40}$/.test(commit) || !/^[0-9a-f]{40}$/.test(expectedTagObject)) {
core.setFailed("dispatched tag object and commit must be full lowercase SHA-1 values");
return;
}
if (isRecovery && !/^[1-9]\d*$/.test(failedRunId)) {
core.setFailed("recover_failed_run_id must be a positive workflow run ID");
return;
}
if (isRecovery && !/^[1-9]\d*$/.test(failedRunAttempt)) {
core.setFailed("recover_failed_run_attempt must be a positive run attempt");
return;
}
}
const ref = await github.rest.git.getRef({ owner, repo, ref: `tags/${version}` });
if (ref.data.object.type !== "tag") {
core.setFailed(`${version} is not an annotated tag`);
return;
}
const tagObject = ref.data.object.sha;
const tag = await github.rest.git.getTag({ owner, repo, tag_sha: tagObject });
if (tag.data.tag !== version || tag.data.object.type !== "commit") {
core.setFailed(`${version} does not resolve to one annotated commit tag`);
return;
}
if (tag.data.object.sha !== commit) {
core.setFailed(`${version} resolves to ${tag.data.object.sha}, expected ${commit}`);
return;
}
if ((isRecovery || isCreate) && tagObject !== expectedTagObject) {
core.setFailed(`${version} tag object is ${tagObject}, expected ${expectedTagObject}`);
return;
}
const tagFields = new Map();
for (const line of tag.data.message.split(/\r?\n/)) {
const separator = line.indexOf(": ");
if (separator < 1) continue;
const key = line.slice(0, separator);
if (tagFields.has(key)) {
core.setFailed(`${version} annotated tag contains duplicate ${key} metadata`);
return;
}
tagFields.set(key, line.slice(separator + 2));
}
const ossMirror = tagFields.has("OSS-Mirror")
? tagFields.get("OSS-Mirror")
: "enabled";
if (!["enabled", "deferred"].includes(ossMirror)) {
core.setFailed(`${version} contains invalid OSS-Mirror metadata`);
return;
}
const cloudOnlyKeys = [
"Release-Run",
"Release-Run-Attempt",
"Requested-By",
"Requested-By-ID",
"Sealed-Commit",
"Workflow-Commit",
"Allocation-Fingerprint",
];
const cloudKeys = ["Channel", ...cloudOnlyKeys];
const hasAnyCloudMetadata = cloudOnlyKeys.some((key) => tagFields.has(key));
const isCloudSeal = cloudKeys.every((key) => tagFields.has(key));
if (hasAnyCloudMetadata && !isCloudSeal) {
core.setFailed(`${version} contains incomplete cloud seal metadata`);
return;
}
if (isCloudSeal) {
const expectedChannel = version.includes("-beta.") ? "prerelease" : "stable";
const actorId = String(context.payload.sender?.id || "");
const sealedRunAttempt = Number(tagFields.get("Release-Run-Attempt"));
const currentRunAttempt = Number(process.env.GITHUB_RUN_ATTEMPT);
if (
tagFields.get("Channel") !== expectedChannel ||
tagFields.get("Sealed-Commit") !== commit ||
tagFields.get("Workflow-Commit") !== commit ||
!/^[0-9a-f]{64}$/.test(tagFields.get("Allocation-Fingerprint")) ||
!/^[1-9]\d*$/.test(tagFields.get("Release-Run")) ||
!/^[1-9]\d*$/.test(tagFields.get("Release-Run-Attempt")) ||
!/^[1-9]\d*$/.test(tagFields.get("Requested-By-ID"))
) {
core.setFailed(`${version} cloud seal metadata does not match its tag authority`);
return;
}
if (isCreate && (
tagFields.get("Release-Run") !== String(context.runId) ||
!Number.isSafeInteger(sealedRunAttempt) ||
!Number.isSafeInteger(currentRunAttempt) ||
sealedRunAttempt < 1 ||
sealedRunAttempt > currentRunAttempt ||
tagFields.get("Requested-By") !== context.actor ||
tagFields.get("Requested-By-ID") !== actorId
)) {
core.setFailed(`${version} cloud seal is not bound to this release request`);
return;
}
} else if (isCreate) {
core.setFailed(`${version} is missing cloud seal metadata`);
return;
}
if (isRecovery || isCreate) {
const defaultBranch = context.payload.repository.default_branch;
const branch = await github.rest.repos.getBranch({ owner, repo, branch: defaultBranch });
const comparison = await github.rest.repos.compareCommitsWithBasehead({
owner,
repo,
basehead: `${commit}...${branch.data.commit.sha}`,
});
if (!["ahead", "identical"].includes(comparison.data.status)) {
core.setFailed(`${version} commit is not contained in ${defaultBranch}`);
return;
}
}
if (isRecovery) {
const failedRun = await github.request(
"GET /repos/{owner}/{repo}/actions/runs/{run_id}/attempts/{attempt_number}",
{
owner,
repo,
run_id: Number(failedRunId),
attempt_number: Number(failedRunAttempt),
},
);
const run = failedRun.data;
const failedByCloud = isCloudSeal;
const expectedBranch = failedByCloud
? context.payload.repository.default_branch
: version;
const expectedEvent = failedByCloud ? "workflow_dispatch" : "push";
if (
run.id !== Number(failedRunId) ||
run.run_attempt !== Number(failedRunAttempt) ||
run.repository.full_name !== `${owner}/${repo}` ||
run.path !== ".github/workflows/release.yml" ||
run.event !== expectedEvent ||
run.status !== "completed" ||
!["failure", "cancelled", "timed_out", "startup_failure", "stale"].includes(run.conclusion) ||
run.head_branch !== expectedBranch ||
run.head_sha !== commit
) {
core.setFailed(
`run ${failedRunId} attempt ${failedRunAttempt} is not the failed sealed Release attempt for ${version}`,
);
return;
}
if (failedByCloud && (
tagFields.get("Release-Run") !== failedRunId ||
tagFields.get("Release-Run-Attempt") !== failedRunAttempt ||
tagFields.get("Requested-By") !== String(run.actor?.login || "") ||
tagFields.get("Requested-By-ID") !== String(run.actor?.id || "")
)) {
core.setFailed(
`run ${failedRunId} attempt ${failedRunAttempt} is not bound by the cloud seal for ${version}`,
);
return;
}
try {
const release = await github.rest.repos.getReleaseByTag({ owner, repo, tag: version });
if (!release.data.draft) {
core.setFailed(`${version} already has a public release; use a channel repair instead`);
return;
}
} catch (error) {
if (error.status !== 404) throw error;
}
}
core.setOutput("release_version", version);
core.setOutput("release_commit", commit);
core.setOutput("release_tag_object", tagObject);
core.setOutput("oss_mirror", ossMirror);
core.setOutput("is_recovery", isRecovery ? "true" : "false");
core.setOutput("failed_run_id", failedRunId);
# create_release already ran this exact source contract and its delivered
# baselines in release-plan. The seal binds that plan's commit and complete
# tag-ref fingerprint; the target step above then verifies the resulting
# annotated tag object and cloud metadata. Tag pushes and recoveries have
# no same-run candidate proof, so they retain the full independent path.
- name: Check out repository
if: ${{ needs.dispatch-contract.outputs.mode != 'create_release' }}
uses: actions/checkout@v4
with:
ref: ${{ steps.target.outputs.release_commit }}
persist-credentials: false
fetch-depth: 0
- name: Check out trusted release tooling
if: ${{ needs.dispatch-contract.outputs.mode != 'create_release' }}
uses: actions/checkout@v4
with:
ref: ${{ github.sha }}
path: tmp/trusted-release-tooling
persist-credentials: false
- name: Fetch release branch and official tags
if: ${{ needs.dispatch-contract.outputs.mode != 'create_release' }}
run: |
git fetch --force origin \
'+refs/heads/main:refs/remotes/origin/main'
git fetch --force --no-tags \
https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli.git \
'+refs/tags/v*:refs/tags/v*' \
'+refs/tags/withdrawn/v*:refs/tags/withdrawn/v*'
- name: Validate release contract
if: ${{ needs.dispatch-contract.outputs.mode != 'create_release' }}
id: contract
env:
RELEASE_VERSION: ${{ steps.target.outputs.release_version }}
run: |
set -eu
. ./scripts/release/release-lib.sh
channel="$(release_channel_for_version "$RELEASE_VERSION")"
./scripts/release/release-contract.sh \
--channel "$channel" \
--version "$RELEASE_VERSION" \
--context ci \
--remote origin \
--branch main \
--notes-output "$RUNNER_TEMP/release-notes.md" \
--metadata-output "$GITHUB_OUTPUT"
- name: Verify remote annotated tag authority
if: ${{ needs.dispatch-contract.outputs.mode != 'create_release' }}
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
RELEASE_VERSION: ${{ steps.target.outputs.release_version }}
RELEASE_COMMIT: ${{ steps.target.outputs.release_commit }}
RELEASE_TAG_OBJECT: ${{ steps.target.outputs.release_tag_object }}
run: |
"$GITHUB_WORKSPACE/tmp/trusted-release-tooling/scripts/release/verify-github-tag-authority.sh" \
"$RELEASE_VERSION" "$RELEASE_COMMIT" "$RELEASE_TAG_OBJECT"
- name: Require successful Code Admission contexts on the sealed commit
uses: actions/github-script@v7
env:
RELEASE_COMMIT: ${{ steps.target.outputs.release_commit }}
with:
script: |
const { owner, repo } = context.repo;
const sha = process.env.RELEASE_COMMIT;
const requiredContexts = [
"Lint",
"Test",
"Coverage",
"Policy",
"Edition",
"Interface Integrity",
"AI Behavior",
"CLI Smoke",
"Mock MCP",
];
const runs = await github.paginate(github.rest.checks.listForRef, {
owner,
repo,
ref: sha,
filter: "latest",
per_page: 100,
});
const latestByName = new Map();
for (const run of runs) {
if (run.head_sha !== sha || !requiredContexts.includes(run.name)) {
continue;
}
const current = latestByName.get(run.name);
if (!current || run.id > current.id) {
latestByName.set(run.name, run);
}
}
const missing = requiredContexts.filter((name) => !latestByName.has(name));
const nonSuccess = requiredContexts.flatMap((name) => {
const run = latestByName.get(name);
if (!run || run.conclusion === "success") {
return [];
}
return [`${name}=${run.conclusion || run.status || "unknown"}`];
});
if (missing.length > 0 || nonSuccess.length > 0) {
core.setFailed(
`Code Admission contexts are not all successful for ${sha}; ` +
`missing: ${missing.length > 0 ? missing.join(", ") : "none"}; ` +
`non-success: ${nonSuccess.length > 0 ? nonSuccess.join(", ") : "none"}`,
);
}
- name: Require delivered previous stable baseline
if: ${{ github.event_name == 'push' || needs.dispatch-contract.outputs.mode == 'recover_release' }}
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
PREVIOUS_STABLE: ${{ steps.contract.outputs.previous_stable }}
PREVIOUS_STABLE_COMMIT: ${{ steps.contract.outputs.previous_stable_commit }}
run: |
"$GITHUB_WORKSPACE/tmp/trusted-release-tooling/scripts/release/verify-delivered-stable.sh" \
"$PREVIOUS_STABLE" "$PREVIOUS_STABLE_COMMIT"
- name: Require release governance token
env:
RELEASE_GOVERNANCE_TOKEN: ${{ secrets.RELEASE_GOVERNANCE_TOKEN }}
run: |
test -n "$RELEASE_GOVERNANCE_TOKEN" || {
echo "RELEASE_GOVERNANCE_TOKEN with repository Administration read permission is required" >&2
exit 1
}
- name: Require immutable releases governance
uses: actions/github-script@v7
with:
# GITHUB_TOKEN cannot read repository administration settings. This
# dedicated credential needs only repository Administration: read.
github-token: ${{ secrets.RELEASE_GOVERNANCE_TOKEN }}
script: |
const { owner, repo } = context.repo;
const response = await github.request(
"GET /repos/{owner}/{repo}/immutable-releases",
{
owner,
repo,
headers: { "X-GitHub-Api-Version": "2026-03-10" },
},
);
if (response.data.enabled !== true) {
core.setFailed("Immutable releases must be enabled before publishing");
}
- name: Require successful beta delivery before stable promotion
if: ${{ steps.contract.outputs.channel == 'stable' && (github.event_name == 'push' || needs.dispatch-contract.outputs.mode == 'recover_release') }}
uses: actions/github-script@v7
env:
FROM_BETA: ${{ steps.contract.outputs.from_beta }}
FROM_BETA_COMMIT: ${{ steps.contract.outputs.from_beta_commit }}
with:
script: |
const { owner, repo } = context.repo;
const expectedAssets = new Set([
"dws-darwin-amd64.tar.gz",
"dws-darwin-arm64.tar.gz",
"dws-linux-amd64.tar.gz",
"dws-linux-arm64.tar.gz",
"dws-windows-amd64.zip",
"dws-windows-arm64.zip",
"dws-skills.zip",
"checksums.txt",
]);
const { data: release } = await github.rest.repos.getReleaseByTag({
owner,
repo,
tag: process.env.FROM_BETA,
});
if (release.draft || !release.prerelease || !release.immutable) {
core.setFailed(`${process.env.FROM_BETA} is not a published immutable prerelease`);
return;
}
const assetNames = release.assets.map((asset) => asset.name);
const names = new Set(assetNames);
const missing = [...expectedAssets].filter((name) => !names.has(name));
const extra = assetNames.filter((name) => !expectedAssets.has(name));
if (
missing.length > 0 ||
extra.length > 0 ||
assetNames.length !== expectedAssets.size
) {
core.setFailed(
`beta release asset set differs: missing=${missing.join(",")} extra=${extra.join(",")}`,
);
return;
}
- name: Verify beta Release workflow delivery
if: ${{ steps.contract.outputs.channel == 'stable' && (github.event_name == 'push' || needs.dispatch-contract.outputs.mode == 'recover_release') }}
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
FROM_BETA: ${{ steps.contract.outputs.from_beta }}
FROM_BETA_COMMIT: ${{ steps.contract.outputs.from_beta_commit }}
run: |
"$GITHUB_WORKSPACE/tmp/trusted-release-tooling/scripts/release/verify-release-workflow-delivery.sh" \
"$FROM_BETA" "$FROM_BETA_COMMIT"
- name: Resolve validated release metadata
id: metadata
env:
DISPATCH_MODE: ${{ needs.dispatch-contract.outputs.mode }}
PLAN_CHANNEL: ${{ needs.release-plan.outputs.channel }}
PLAN_PREVIOUS_STABLE: ${{ needs.release-plan.outputs.previous_stable }}
PLAN_PREVIOUS_STABLE_COMMIT: ${{ needs.release-plan.outputs.previous_stable_commit }}
PLAN_FROM_BETA: ${{ needs.release-plan.outputs.from_beta }}
PLAN_FROM_BETA_COMMIT: ${{ needs.release-plan.outputs.from_beta_commit }}
CONTRACT_CHANNEL: ${{ steps.contract.outputs.channel }}
CONTRACT_PREVIOUS_STABLE: ${{ steps.contract.outputs.previous_stable }}
CONTRACT_PREVIOUS_STABLE_COMMIT: ${{ steps.contract.outputs.previous_stable_commit }}
CONTRACT_FROM_BETA: ${{ steps.contract.outputs.from_beta }}
CONTRACT_FROM_BETA_COMMIT: ${{ steps.contract.outputs.from_beta_commit }}
run: |
set -eu
if test "$DISPATCH_MODE" = create_release; then
channel="$PLAN_CHANNEL"
previous_stable="$PLAN_PREVIOUS_STABLE"
previous_stable_commit="$PLAN_PREVIOUS_STABLE_COMMIT"
from_beta="$PLAN_FROM_BETA"
from_beta_commit="$PLAN_FROM_BETA_COMMIT"
else
channel="$CONTRACT_CHANNEL"
previous_stable="$CONTRACT_PREVIOUS_STABLE"
previous_stable_commit="$CONTRACT_PREVIOUS_STABLE_COMMIT"
from_beta="$CONTRACT_FROM_BETA"
from_beta_commit="$CONTRACT_FROM_BETA_COMMIT"
fi
test -n "$channel"
test -n "$previous_stable"
test -n "$previous_stable_commit"
case "$channel" in
prerelease)
test -z "$from_beta"
test -z "$from_beta_commit"
;;
stable)
test -n "$from_beta"
test -n "$from_beta_commit"
;;
*)
echo "unsupported validated release channel: $channel" >&2
exit 2
;;
esac
{
printf 'channel=%s\n' "$channel"
printf 'previous_stable=%s\n' "$previous_stable"
printf 'previous_stable_commit=%s\n' "$previous_stable_commit"
printf 'from_beta=%s\n' "$from_beta"
printf 'from_beta_commit=%s\n' "$from_beta_commit"
} >> "$GITHUB_OUTPUT"
release:
name: Build signed release artifacts
if: ${{ !cancelled() && needs.release-contract.result == 'success' }}
needs: [release-contract]
runs-on: ubuntu-latest
timeout-minutes: 60
permissions:
contents: read
env:
RELEASE_VERSION: ${{ needs.release-contract.outputs.release_version }}
RELEASE_COMMIT: ${{ needs.release-contract.outputs.release_commit }}
RELEASE_TAG_OBJECT: ${{ needs.release-contract.outputs.release_tag_object }}
steps:
- name: Check out repository
uses: actions/checkout@v4
with:
ref: ${{ needs.release-contract.outputs.release_commit }}
persist-credentials: false
fetch-depth: 0
- name: Check out trusted release tooling
uses: actions/checkout@v4
with:
ref: ${{ github.sha }}
path: tmp/trusted-release-tooling
persist-credentials: false
- name: Fetch release branch and official tags
run: |
git fetch --force origin \
'+refs/heads/main:refs/remotes/origin/main'
git fetch --force --no-tags \
https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli.git \
'+refs/tags/v*:refs/tags/v*' \
'+refs/tags/withdrawn/v*:refs/tags/withdrawn/v*'
- name: Verify sealed tag before executing release source
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
"$GITHUB_WORKSPACE/tmp/trusted-release-tooling/scripts/release/verify-github-tag-authority.sh" \
"$RELEASE_VERSION" "$RELEASE_COMMIT" "$RELEASE_TAG_OBJECT"
- name: Revalidate contract and render release notes
run: |
./scripts/release/release-contract.sh \
--channel "${{ needs.release-contract.outputs.channel }}" \
--version "$RELEASE_VERSION" \
--context ci \
--remote origin \
--branch main \
--notes-output "$RUNNER_TEMP/release-notes.md"
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version-file: go.mod
- name: Install rcodesign (sign darwin binaries from Linux)
run: |
set -euo pipefail
RCS_VERSION="0.29.0"
RCS_ARCHIVE_SHA256="dbe85cedd8ee4217b64e9a0e4c2aef92ab8bcaaa41f20bde99781ff02e600002"
curl -fsSL -o /tmp/rcodesign.tar.gz \
"https://github.com/indygreg/apple-platform-rs/releases/download/apple-codesign%2F${RCS_VERSION}/apple-codesign-${RCS_VERSION}-x86_64-unknown-linux-musl.tar.gz"
printf '%s %s\n' "$RCS_ARCHIVE_SHA256" /tmp/rcodesign.tar.gz \
| sha256sum --check --strict -
mkdir -p /tmp/rcodesign
tar -xzf /tmp/rcodesign.tar.gz -C /tmp/rcodesign --strip-components=1
sudo install -m 0755 /tmp/rcodesign/rcodesign /usr/local/bin/rcodesign
rcodesign --version
- name: Prepare Apple Developer ID certificate
env:
APPLE_CERTIFICATE_P12_BASE64: ${{ secrets.APPLE_CERTIFICATE_P12_BASE64 }}
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
run: |
set -euo pipefail
if [ -z "${APPLE_CERTIFICATE_P12_BASE64:-}" ] || [ -z "${APPLE_CERTIFICATE_PASSWORD:-}" ]; then
if [ "$GITHUB_REPOSITORY_OWNER" = "DingTalk-Real-AI" ]; then
echo "APPLE_CERTIFICATE_P12_BASE64 and APPLE_CERTIFICATE_PASSWORD are required for official releases" >&2
exit 1
fi
echo "Developer ID secrets are unavailable; fork release will use ad-hoc signing."
exit 0
fi
umask 077
certificate_path="$RUNNER_TEMP/dws-developer-id.p12"
password_path="$RUNNER_TEMP/dws-developer-id-password"
printf '%s' "$APPLE_CERTIFICATE_P12_BASE64" | base64 --decode > "$certificate_path"
printf '%s' "$APPLE_CERTIFICATE_PASSWORD" > "$password_path"
# Fail before packaging if the secret is corrupt or the password is wrong.
# The exported P12 may use legacy PKCS#12 ciphers; OpenSSL 3 requires
# -legacy to validate those containers even though rcodesign can read them.
openssl pkcs12 -legacy -in "$certificate_path" -passin "file:$password_path" -noout
echo "DWS_APPLE_CERTIFICATE_P12=$certificate_path" >> "$GITHUB_ENV"
echo "DWS_APPLE_CERTIFICATE_PASSWORD_FILE=$password_path" >> "$GITHUB_ENV"
- name: Require a clean sealed source before GoReleaser
run: |
dirty="$(git status --porcelain --untracked-files=all)"
test -z "$dirty" || {
echo "sealed release source became dirty before GoReleaser:" >&2
printf '%s\n' "$dirty" >&2
exit 1
}
- name: Build release artifacts without publishing
uses: goreleaser/goreleaser-action@v6
with:
version: "v2.16.0"
args: release --clean --skip=publish --release-notes=${{ runner.temp }}/release-notes.md
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GORELEASER_CURRENT_TAG: ${{ needs.release-contract.outputs.release_version }}
GORELEASER_PREVIOUS_TAG: ${{ needs.release-contract.outputs.previous_stable }}
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: "20"
registry-url: "https://registry.npmjs.org"
- name: Install archive tooling
run: |
if command -v zip >/dev/null 2>&1 && command -v unzip >/dev/null 2>&1; then
exit 0
fi
sudo apt-get update
sudo apt-get install -y zip unzip
- name: Post-release packaging
run: ./scripts/release/post-goreleaser.sh
env:
DWS_PACKAGE_VERSION: ${{ needs.release-contract.outputs.release_version }}
DWS_RELEASE_BASE_URL: https://github.com/${{ github.repository }}/releases/download/${{ needs.release-contract.outputs.release_version }}
DWS_REQUIRE_DEVELOPER_ID_SIGNING: ${{ github.repository_owner == 'DingTalk-Real-AI' }}
- name: Remove Apple Developer ID certificate
if: ${{ always() }}
run: |
rm -f "$RUNNER_TEMP/dws-developer-id.p12"
rm -f "$RUNNER_TEMP/dws-developer-id-password"
- name: Verify final release artifacts
run: ./scripts/release/verify-release-artifacts.sh "$RELEASE_VERSION"
- name: Verify npm package before sealing GitHub Release
run: ./scripts/release/verify-package-managers.sh --npm-only --expected-version "$RELEASE_VERSION"
- name: Reverify remote tag before sealing GitHub Release
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
"$GITHUB_WORKSPACE/tmp/trusted-release-tooling/scripts/release/verify-github-tag-authority.sh" \
"$RELEASE_VERSION" "$RELEASE_COMMIT" "$RELEASE_TAG_OBJECT"
- name: Preserve finalized distribution files
uses: actions/upload-artifact@v4
with:
name: finalized-release-dist
path: dist/
if-no-files-found: error
retention-days: 1
verify-darwin-signatures:
name: Verify Apple Developer ID signatures
if: ${{ !cancelled() && needs.release-contract.result == 'success' && needs.release.result == 'success' }}
needs: [release-contract, release]
runs-on: macos-latest
timeout-minutes: 10
permissions:
contents: read
steps:
- name: Restore finalized distribution files
uses: actions/download-artifact@v4
with:
name: finalized-release-dist
path: dist
- name: Verify finalized Darwin signatures with Apple codesign
run: |
set -euo pipefail
for arch in amd64 arm64; do
archive="dist/dws-darwin-${arch}.tar.gz"
stage="$RUNNER_TEMP/verify-darwin-${arch}"
mkdir -p "$stage"
tar -xzf "$archive" -C "$stage"
test -f "$stage/dws"
codesign --verify --strict --verbose=4 "$stage/dws"
codesign -dvvv "$stage/dws"
done
publish-release:
name: Publish immutable GitHub Release
if: ${{ !cancelled() && needs.release-contract.result == 'success' && needs.release-validation.result == 'success' && needs.release.result == 'success' && needs.verify-darwin-signatures.result == 'success' }}
needs: [release-contract, release-validation, release, verify-darwin-signatures]
runs-on: ubuntu-latest
timeout-minutes: 30
outputs:
coverage_baseline_required: ${{ steps.seal-formula.outputs.coverage_baseline_required }}
coverage_baseline_commit: ${{ steps.seal-formula.outputs.coverage_baseline_commit }}
permissions:
checks: write
contents: write
env:
RELEASE_VERSION: ${{ needs.release-contract.outputs.release_version }}
RELEASE_COMMIT: ${{ needs.release-contract.outputs.release_commit }}
RELEASE_TAG_OBJECT: ${{ needs.release-contract.outputs.release_tag_object }}
IS_RECOVERY: ${{ needs.release-contract.outputs.is_recovery }}
steps:
- name: Check out sealed release source
uses: actions/checkout@v4
with:
ref: ${{ needs.release-contract.outputs.release_commit }}
persist-credentials: false
fetch-depth: 0
- name: Check out trusted release tooling
uses: actions/checkout@v4
with:
ref: ${{ github.sha }}
path: tmp/trusted-release-tooling
persist-credentials: false
- name: Fetch release branch and official tags
run: |
git fetch --force origin \
'+refs/heads/main:refs/remotes/origin/main'
git fetch --force --no-tags \
https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli.git \
'+refs/tags/v*:refs/tags/v*' \
'+refs/tags/withdrawn/v*:refs/tags/withdrawn/v*'
- name: Verify sealed tag before executing publication source
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
"$GITHUB_WORKSPACE/tmp/trusted-release-tooling/scripts/release/verify-github-tag-authority.sh" \
"$RELEASE_VERSION" "$RELEASE_COMMIT" "$RELEASE_TAG_OBJECT"
- name: Revalidate contract and render release notes
run: |
./scripts/release/release-contract.sh \
--channel "${{ needs.release-contract.outputs.channel }}" \
--version "$RELEASE_VERSION" \
--context ci \
--remote origin \
--branch main \
--notes-output "$RUNNER_TEMP/release-notes.md"
- name: Bind recovery publication to this workflow run
if: ${{ needs.release-contract.outputs.is_recovery == 'true' }}
run: |
printf '\n<!-- dws-release-recovery run=%s tag-object=%s commit=%s -->\n' \
"$GITHUB_RUN_ID" "$RELEASE_TAG_OBJECT" "$RELEASE_COMMIT" \
>> "$RUNNER_TEMP/release-notes.md"
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: "20"
registry-url: "https://registry.npmjs.org"
- name: Install archive tooling
run: sudo apt-get update && sudo apt-get install -y zip unzip
- name: Restore finalized distribution files
uses: actions/download-artifact@v4
with:
name: finalized-release-dist
path: dist
- name: Verify finalized release artifacts before publication
run: ./scripts/release/verify-release-artifacts.sh "$RELEASE_VERSION"
- name: Verify npm package before sealing GitHub Release
run: ./scripts/release/verify-package-managers.sh --npm-only --expected-version "$RELEASE_VERSION"
- name: Reverify remote tag before sealing GitHub Release
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
"$GITHUB_WORKSPACE/tmp/trusted-release-tooling/scripts/release/verify-github-tag-authority.sh" \
"$RELEASE_VERSION" "$RELEASE_COMMIT" "$RELEASE_TAG_OBJECT"
- name: Publish or reuse immutable GitHub Release
id: publish
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
RELEASE_CHANNEL: ${{ needs.release-contract.outputs.channel }}
run: |
set -eu
expected_prerelease=false
prerelease_flag=""
if [ "$RELEASE_CHANNEL" = "prerelease" ]; then
expected_prerelease=true
prerelease_flag="--prerelease"
fi
release_id=""
if gh release view "$RELEASE_VERSION" >/dev/null 2>&1; then
release_id="$(
gh release view "$RELEASE_VERSION" \
--json databaseId \
--jq .databaseId
)"
printf '%s\n' "$release_id" | grep -Eq '^[1-9][0-9]*$' || {
echo "Existing GitHub Release has an invalid database ID: $release_id" >&2
exit 1
}
resolved_tag="$(
gh api -H 'X-GitHub-Api-Version: 2026-03-10' \
"repos/$GITHUB_REPOSITORY/releases/$release_id" \
--jq .tag_name
)"
test "$resolved_tag" = "$RELEASE_VERSION" || {
echo "GitHub Release ID $release_id targets $resolved_tag, not $RELEASE_VERSION." >&2
exit 1
}
is_draft="$(
gh api -H 'X-GitHub-Api-Version: 2026-03-10' \
"repos/$GITHUB_REPOSITORY/releases/$release_id" \
--jq .draft
)"
is_prerelease="$(
gh api -H 'X-GitHub-Api-Version: 2026-03-10' \
"repos/$GITHUB_REPOSITORY/releases/$release_id" \
--jq .prerelease
)"
if [ "$is_prerelease" != "$expected_prerelease" ]; then
echo "Existing release channel does not match $RELEASE_CHANNEL." >&2
exit 1
fi
if [ "$is_draft" = "false" ]; then
is_immutable="$(
gh api -H 'X-GitHub-Api-Version: 2026-03-10' \
"repos/$GITHUB_REPOSITORY/releases/$release_id" \
--jq .immutable
)"
if [ "$is_immutable" != "true" ]; then
echo "Published release is mutable; refusing to reuse its assets." >&2
exit 1
fi
remote_body="$(
gh api -H 'X-GitHub-Api-Version: 2026-03-10' \
"repos/$GITHUB_REPOSITORY/releases/$release_id" \
--jq .body
)"
local_body="$(cat "$RUNNER_TEMP/release-notes.md")"
if [ "$remote_body" != "$local_body" ]; then
echo "Published release notes differ from the sealed CHANGELOG." >&2
exit 1
fi
if [ "$IS_RECOVERY" = "true" ]; then
recovery_marker="<!-- dws-release-recovery run=$GITHUB_RUN_ID tag-object=$RELEASE_TAG_OBJECT commit=$RELEASE_COMMIT -->"
case "$remote_body" in
*"$recovery_marker"*) ;;
*)
echo "Public release is not bound to this exact recovery run." >&2
exit 1
;;
esac
fi
published_dir="$RUNNER_TEMP/published-release"
mkdir -p "$published_dir"
DWS_GITHUB_RELEASE_ID="$release_id" \
"$GITHUB_WORKSPACE/tmp/trusted-release-tooling/scripts/release/download-github-release-assets.sh" \
"$RELEASE_VERSION" "$published_dir"
DWS_PACKAGE_DIST_DIR="$published_dir" \
./scripts/release/verify-release-artifacts.sh "$RELEASE_VERSION"
for asset in "$published_dir"/dws-* "$published_dir"/checksums.txt; do
if [ "$IS_RECOVERY" = "true" ] && ! cmp -s "$asset" "dist/$(basename "$asset")"; then
echo "Public recovery asset differs from this run's sealed artifact: $(basename "$asset")" >&2
exit 1
fi
cp "$asset" dist/
done
DWS_PACKAGE_DIST_DIR="$GITHUB_WORKSPACE/dist" \
DWS_PACKAGE_SOURCE_ROOT="$GITHUB_WORKSPACE" \
./scripts/release/stage-npm-package.sh "$RELEASE_VERSION"
echo "release_id=$release_id" >> "$GITHUB_OUTPUT"
echo "Published GitHub Release is immutable and verified; reusing its exact assets."
exit 0
fi
else
# shellcheck disable=SC2086
gh release create "$RELEASE_VERSION" \
--verify-tag \
--draft \
--title "$RELEASE_VERSION" \
--notes-file "$RUNNER_TEMP/release-notes.md" \
$prerelease_flag
release_id="$(
gh release view "$RELEASE_VERSION" \
--json databaseId \
--jq .databaseId
)"
fi
printf '%s\n' "$release_id" | grep -Eq '^[1-9][0-9]*$' || {
echo "Draft GitHub Release has an invalid database ID: $release_id" >&2
exit 1
}
resolved_tag="$(
gh api -H 'X-GitHub-Api-Version: 2026-03-10' \
"repos/$GITHUB_REPOSITORY/releases/$release_id" \
--jq .tag_name
)"
test "$resolved_tag" = "$RELEASE_VERSION" || {
echo "Draft GitHub Release ID $release_id targets $resolved_tag, not $RELEASE_VERSION." >&2
exit 1
}
gh api --method PATCH \
-H 'X-GitHub-Api-Version: 2026-03-10' \
"repos/$GITHUB_REPOSITORY/releases/$release_id" \
-f tag_name="$RELEASE_VERSION" \
-f target_commitish="$RELEASE_COMMIT" \
-f name="$RELEASE_VERSION" \
-f body="$(cat "$RUNNER_TEMP/release-notes.md")" \
-F draft=true \
-F prerelease="$expected_prerelease" \
>/dev/null
draft_state="$(
gh api -H 'X-GitHub-Api-Version: 2026-03-10' \
"repos/$GITHUB_REPOSITORY/releases/$release_id" \
--jq '[.tag_name, .draft, .prerelease] | @tsv'
)"
test "$draft_state" = "$(printf '%s\ttrue\t%s' "$RELEASE_VERSION" "$expected_prerelease")" || {
echo "Draft GitHub Release identity or channel changed before upload." >&2
exit 1
}
draft_body="$(
gh api -H 'X-GitHub-Api-Version: 2026-03-10' \
"repos/$GITHUB_REPOSITORY/releases/$release_id" \
--jq .body
)"
local_body="$(cat "$RUNNER_TEMP/release-notes.md")"
test "$draft_body" = "$local_body" || {
echo "Draft GitHub Release notes differ from the sealed CHANGELOG." >&2
exit 1
}
if [ "$IS_RECOVERY" = "true" ]; then
recovery_marker="<!-- dws-release-recovery run=$GITHUB_RUN_ID tag-object=$RELEASE_TAG_OBJECT commit=$RELEASE_COMMIT -->"
case "$draft_body" in
*"$recovery_marker"*) ;;
*)
echo "Draft GitHub Release is not bound to this exact recovery run." >&2
exit 1
;;
esac
fi
gh release upload "$RELEASE_VERSION" \
dist/dws-*.tar.gz \
dist/dws-windows-*.zip \
dist/dws-skills.zip \
dist/checksums.txt \
--clobber
uploaded_release_id="$(
gh release view "$RELEASE_VERSION" \
--json databaseId \
--jq .databaseId
)"
test "$uploaded_release_id" = "$release_id" || {
echo "GitHub Release identity changed during upload: $release_id -> $uploaded_release_id" >&2
exit 1
}
DWS_GITHUB_RELEASE_ID="$release_id" \
"$GITHUB_WORKSPACE/tmp/trusted-release-tooling/scripts/release/verify-github-release-assets.sh" \
"$RELEASE_VERSION"
sealed_upload="$RUNNER_TEMP/sealed-upload"
rm -rf "$sealed_upload"
mkdir -p "$sealed_upload"
DWS_GITHUB_RELEASE_ID="$release_id" \
"$GITHUB_WORKSPACE/tmp/trusted-release-tooling/scripts/release/download-github-release-assets.sh" \
"$RELEASE_VERSION" "$sealed_upload"
DWS_PACKAGE_DIST_DIR="$sealed_upload" \
./scripts/release/verify-release-artifacts.sh "$RELEASE_VERSION"
for remote_asset in "$sealed_upload"/dws-* "$sealed_upload"/checksums.txt; do
local_asset="dist/$(basename "$remote_asset")"
cmp -s "$local_asset" "$remote_asset" || {
echo "Uploaded GitHub asset differs from local sealed bytes: $(basename "$remote_asset")" >&2
exit 1
}
done
gh api --method PATCH \
-H 'X-GitHub-Api-Version: 2026-03-10' \
"repos/$GITHUB_REPOSITORY/releases/$release_id" \
-f name="$RELEASE_VERSION" \
-f body="$(cat "$RUNNER_TEMP/release-notes.md")" \
-F prerelease="$expected_prerelease" \
-F draft=false \
>/dev/null
echo "release_id=$release_id" >> "$GITHUB_OUTPUT"
- name: Require immutable published GitHub Release
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
RELEASE_ID: ${{ steps.publish.outputs.release_id }}
RELEASE_CHANNEL: ${{ needs.release-contract.outputs.channel }}
run: |
set -eu
expected_prerelease=false
if [ "$RELEASE_CHANNEL" = "prerelease" ]; then expected_prerelease=true; fi
printf '%s\n' "$RELEASE_ID" | grep -Eq '^[1-9][0-9]*$' || {
echo "Published GitHub Release has an invalid database ID: $RELEASE_ID" >&2
exit 1
}
release_state="$(
gh api -H 'X-GitHub-Api-Version: 2026-03-10' \
"repos/$GITHUB_REPOSITORY/releases/$RELEASE_ID" \
--jq '[.tag_name, .draft, .prerelease, .immutable] | @tsv'
)"
test "$release_state" = "$(printf '%s\tfalse\t%s\ttrue' "$RELEASE_VERSION" "$expected_prerelease")" || {
echo "GitHub Release ID $RELEASE_ID is not the exact public immutable $RELEASE_VERSION release." >&2
exit 1
}
immutable_body="$(
gh api -H 'X-GitHub-Api-Version: 2026-03-10' \
"repos/$GITHUB_REPOSITORY/releases/$RELEASE_ID" \
--jq .body
)"
local_body="$(cat "$RUNNER_TEMP/release-notes.md")"
test "$immutable_body" = "$local_body" || {
echo "Immutable GitHub Release notes differ from the sealed CHANGELOG." >&2
exit 1
}
if [ "$IS_RECOVERY" = "true" ]; then
recovery_marker="<!-- dws-release-recovery run=$GITHUB_RUN_ID tag-object=$RELEASE_TAG_OBJECT commit=$RELEASE_COMMIT -->"
case "$immutable_body" in
*"$recovery_marker"*) ;;
*)
echo "Immutable GitHub Release is not bound to this exact recovery run." >&2
exit 1
;;
esac
fi
DWS_GITHUB_RELEASE_ID="$RELEASE_ID" \
"$GITHUB_WORKSPACE/tmp/trusted-release-tooling/scripts/release/verify-github-release-assets.sh" \
"$RELEASE_VERSION"
immutable_dir="$RUNNER_TEMP/immutable-release"
rm -rf "$immutable_dir"
mkdir -p "$immutable_dir"
DWS_GITHUB_RELEASE_ID="$RELEASE_ID" \
"$GITHUB_WORKSPACE/tmp/trusted-release-tooling/scripts/release/download-github-release-assets.sh" \
"$RELEASE_VERSION" "$immutable_dir"
DWS_PACKAGE_DIST_DIR="$immutable_dir" \
./scripts/release/verify-release-artifacts.sh "$RELEASE_VERSION"
for immutable_asset in "$immutable_dir"/dws-* "$immutable_dir"/checksums.txt; do
sealed_asset="dist/$(basename "$immutable_asset")"
cmp -s "$sealed_asset" "$immutable_asset" || {
echo "Immutable GitHub asset differs from this run's sealed bytes: $(basename "$immutable_asset")" >&2
exit 1
}
done
"$GITHUB_WORKSPACE/tmp/trusted-release-tooling/scripts/release/verify-github-tag-authority.sh" \
"$RELEASE_VERSION" "$RELEASE_COMMIT" "$RELEASE_TAG_OBJECT"
- name: Publish stable Homebrew formula
id: homebrew-stable
if: ${{ github.repository_owner == 'DingTalk-Real-AI' && needs.release-contract.outputs.channel == 'stable' }}
run: DWS_PUBLISH_OUTPUT="$GITHUB_OUTPUT" "$GITHUB_WORKSPACE/tmp/trusted-release-tooling/scripts/release/publish-homebrew-formula.sh"
env:
DWS_FORMULA_SOURCE: ${{ github.workspace }}/dist/homebrew/dingtalk-workspace-cli.rb
DWS_TAP_REPO_URL: https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli.git
DWS_TAP_BRANCH: main
DWS_TAP_GITHUB_TOKEN: ${{ secrets.HOMEBREW_PR_TOKEN }}
DWS_TAP_COMMIT_MESSAGE: "chore: update formula for ${{ needs.release-contract.outputs.release_version }} [skip ci]"
DWS_GIT_NAME: github-actions[bot]
DWS_GIT_EMAIL: 41898282+github-actions[bot]@users.noreply.github.com
- name: Publish beta Homebrew formula
id: homebrew-beta
if: ${{ github.repository_owner == 'DingTalk-Real-AI' && needs.release-contract.outputs.channel == 'prerelease' }}
run: DWS_PUBLISH_OUTPUT="$GITHUB_OUTPUT" "$GITHUB_WORKSPACE/tmp/trusted-release-tooling/scripts/release/publish-homebrew-formula.sh"
env:
DWS_FORMULA_SOURCE: ${{ github.workspace }}/dist/homebrew/dingtalk-workspace-cli-beta.rb
DWS_TAP_FORMULA_PATH: Formula/dingtalk-workspace-cli-beta.rb
DWS_TAP_REPO_URL: https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli.git
DWS_TAP_BRANCH: main
DWS_TAP_GITHUB_TOKEN: ${{ secrets.HOMEBREW_PR_TOKEN }}
DWS_TAP_COMMIT_MESSAGE: "chore: update beta formula for ${{ needs.release-contract.outputs.release_version }} [skip ci]"
DWS_GIT_NAME: github-actions[bot]
DWS_GIT_EMAIL: 41898282+github-actions[bot]@users.noreply.github.com
- name: Seal Formula-only Code Admission contexts
id: seal-formula
if: ${{ github.repository_owner == 'DingTalk-Real-AI' }}
uses: actions/github-script@v7
env:
FORMULA_CHANGED: ${{ steps.homebrew-stable.outputs.formula_changed || steps.homebrew-beta.outputs.formula_changed }}
FORMULA_COMMIT: ${{ steps.homebrew-stable.outputs.published_commit || steps.homebrew-beta.outputs.published_commit }}
RELEASE_CHANNEL: ${{ needs.release-contract.outputs.channel }}
with:
script: |
const fs = require("fs");
const { owner, repo } = context.repo;
const commit = process.env.FORMULA_COMMIT;
const formulaChanged = process.env.FORMULA_CHANGED === "true";
const channel = process.env.RELEASE_CHANNEL;
const version = process.env.RELEASE_VERSION;
const formulaPath = channel === "stable"
? "Formula/dingtalk-workspace-cli.rb"
: "Formula/dingtalk-workspace-cli-beta.rb";
const sourcePath = channel === "stable"
? "dist/homebrew/dingtalk-workspace-cli.rb"
: "dist/homebrew/dingtalk-workspace-cli-beta.rb";
core.setOutput("coverage_baseline_required", "false");
core.setOutput("coverage_baseline_commit", "");
const expectedMessage = channel === "stable"
? `chore: update formula for ${version} [skip ci]`
: `chore: update beta formula for ${version} [skip ci]`;
if (!/^[0-9a-f]{40}$/.test(commit)) {
core.setFailed("Homebrew publisher did not return one full commit SHA");
return;
}
const commitResponse = await github.rest.repos.getCommit({
owner,
repo,
ref: commit,
per_page: 100,
});
const files = commitResponse.data.files || [];
const requiredContexts = [
"Lint",
"Test",
"Coverage",
"Policy",
"Edition",
"Interface Integrity",
"AI Behavior",
"CLI Smoke",
"Mock MCP",
];
const exactFormulaCommit =
commitResponse.data.sha === commit &&
commitResponse.data.parents.length === 1 &&
commitResponse.data.commit.message === expectedMessage &&
commitResponse.data.author?.login === "github-actions[bot]" &&
commitResponse.data.committer?.login === "github-actions[bot]" &&
files.length === 1 &&
files[0].filename === formulaPath &&
["added", "modified"].includes(files[0].status);
if (!exactFormulaCommit) {
if (formulaChanged) {
core.setFailed(
`${commit} is not the exact github-actions Formula-only commit for ${version}`,
);
} else {
core.info("Formula was already current; no Formula-only check sealing is needed.");
}
return;
}
const parent = commitResponse.data.parents[0].sha;
const parentRuns = await github.paginate(github.rest.checks.listForRef, {
owner,
repo,
ref: parent,
filter: "latest",
per_page: 100,
});
const latestParentRunByName = new Map();
for (const run of parentRuns) {
if (
run.head_sha !== parent ||
run.app?.slug !== "github-actions" ||
!requiredContexts.includes(run.name)
) {
continue;
}
const current = latestParentRunByName.get(run.name);
if (!current || run.id > current.id) {
latestParentRunByName.set(run.name, run);
}
}
const invalidParentContexts = requiredContexts.filter((name) => {
const run = latestParentRunByName.get(name);
return !run || run.conclusion !== "success";
});
if (invalidParentContexts.length > 0) {
core.setFailed(
`Formula commit parent ${parent} lacks successful Code Admission contexts: ` +
invalidParentContexts.join(", "),
);
return;
}
const remoteFormula = await github.rest.repos.getContent({
owner,
repo,
path: formulaPath,
ref: commit,
});
if (
Array.isArray(remoteFormula.data) ||
remoteFormula.data.type !== "file" ||
remoteFormula.data.encoding !== "base64"
) {
core.setFailed(`${formulaPath} is not a regular base64 GitHub blob at ${commit}`);
return;
}
const expectedFormula = fs.readFileSync(sourcePath);
const actualFormula = Buffer.from(
remoteFormula.data.content.replace(/\s/g, ""),
"base64",
);
if (!actualFormula.equals(expectedFormula)) {
core.setFailed(`${formulaPath} at ${commit} differs from the verified release Formula`);
return;
}
const branch = await github.rest.repos.getBranch({
owner,
repo,
branch: context.payload.repository.default_branch,
});
const comparison = await github.rest.repos.compareCommitsWithBasehead({
owner,
repo,
basehead: `${commit}...${branch.data.commit.sha}`,
});
if (!["ahead", "identical"].includes(comparison.data.status)) {
core.setFailed(`${commit} is no longer contained in the default branch`);
return;
}
for (const name of requiredContexts) {
await github.rest.checks.create({
owner,
repo,
name,
head_sha: commit,
status: "completed",
conclusion: "success",
output: {
title: "Verified Formula-only release commit",
summary:
`${formulaPath} was generated from immutable ${version} assets, ` +
"validated byte-for-byte, and was the commit's only changed file.",
},
});
}
core.setOutput("coverage_baseline_required", "true");
core.setOutput("coverage_baseline_commit", commit);
core.info(
`Formula-only Code Admission is sealed for ${commit}; the independent confirmation job will dispatch its exact-SHA cache producer.`,
);
- name: Reverify exact immutable npm package
run: ./scripts/release/verify-package-managers.sh --npm-only --expected-version "$RELEASE_VERSION"
publish-channels:
name: Publish npm and mirrors
if: ${{ !cancelled() && needs.release-contract.result == 'success' && needs.publish-release.result == 'success' }}
needs: [release-contract, publish-release]
runs-on: ubuntu-latest
timeout-minutes: 30
permissions:
contents: read
env:
RELEASE_VERSION: ${{ needs.release-contract.outputs.release_version }}
RELEASE_COMMIT: ${{ needs.release-contract.outputs.release_commit }}
RELEASE_TAG_OBJECT: ${{ needs.release-contract.outputs.release_tag_object }}
steps:
- name: Check out sealed release source
uses: actions/checkout@v4
with:
ref: ${{ needs.release-contract.outputs.release_commit }}
persist-credentials: false
fetch-depth: 0
- name: Check out trusted release tooling
uses: actions/checkout@v4
with:
ref: ${{ github.sha }}
path: tmp/trusted-release-tooling
persist-credentials: false
- name: Fetch release branch and official tags
run: |
git fetch --force origin \
'+refs/heads/main:refs/remotes/origin/main'
git fetch --force --no-tags \
https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli.git \
'+refs/tags/v*:refs/tags/v*' \
'+refs/tags/withdrawn/v*:refs/tags/withdrawn/v*'
- name: Verify sealed tag before executing channel source
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
"$GITHUB_WORKSPACE/tmp/trusted-release-tooling/scripts/release/verify-github-tag-authority.sh" \
"$RELEASE_VERSION" "$RELEASE_COMMIT" "$RELEASE_TAG_OBJECT"
- name: Revalidate sealed contract
run: |
./scripts/release/release-contract.sh \
--channel "${{ needs.release-contract.outputs.channel }}" \
--version "$RELEASE_VERSION" \
--context ci \
--remote origin \
--branch main
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: "20"
registry-url: "https://registry.npmjs.org"
- name: Install archive tooling
run: sudo apt-get update && sudo apt-get install -y zip unzip
- name: Download and verify immutable GitHub Release
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
set -eu
release_state="$(
gh api -H 'X-GitHub-Api-Version: 2026-03-10' \
"repos/$GITHUB_REPOSITORY/releases/tags/$RELEASE_VERSION" \
--jq '[.draft, .immutable] | @tsv'
)"
test "$release_state" = "$(printf 'false\ttrue')" || {
echo "GitHub Release must be public and immutable before channel publication." >&2
exit 1
}
./scripts/release/verify-github-release-assets.sh "$RELEASE_VERSION"
"$GITHUB_WORKSPACE/tmp/trusted-release-tooling/scripts/release/verify-github-tag-authority.sh" \
"$RELEASE_VERSION" "$RELEASE_COMMIT" "$RELEASE_TAG_OBJECT"
mkdir -p dist
gh release download "$RELEASE_VERSION" \
--dir dist \
--pattern 'dws-*' \
--pattern 'checksums.txt' \
--clobber
./scripts/release/verify-release-artifacts.sh "$RELEASE_VERSION"
DWS_PACKAGE_SOURCE_ROOT="$GITHUB_WORKSPACE" \
DWS_PACKAGE_DIST_DIR="$GITHUB_WORKSPACE/dist" \
./scripts/release/stage-npm-package.sh "$RELEASE_VERSION"
- name: Verify immutable npm package without publication credentials
run: |
./scripts/release/verify-package-managers.sh \
--npm-only \
--expected-version "$RELEASE_VERSION"
- name: Inspect npm channel state
id: npm-state
if: ${{ github.repository_owner == 'DingTalk-Real-AI' }}
env:
RELEASE_CHANNEL: ${{ needs.release-contract.outputs.channel }}
run: |
set -eu
. ./scripts/release/release-lib.sh
semver="${RELEASE_VERSION#v}"
npm_tag=latest
if [ "$RELEASE_CHANNEL" = "prerelease" ]; then npm_tag=beta; fi
current="$(npm view dingtalk-workspace-cli "dist-tags.$npm_tag")"
local_integrity="$(
./scripts/release/pack-npm-package.sh \
"$GITHUB_WORKSPACE/dist/npm/dingtalk-workspace-cli" \
"$RUNNER_TEMP/dingtalk-workspace-cli.tgz"
)"
version_error="$RUNNER_TEMP/npm-version-error.log"
if existing="$(npm view "dingtalk-workspace-cli@$semver" version 2>"$version_error")"; then
if [ "$existing" != "$semver" ]; then
echo "npm returned unexpected version $existing for $semver" >&2
exit 1
fi
elif grep -Eq 'E404|404 Not Found' "$version_error"; then
existing=""
else
cat "$version_error" >&2
exit 1
fi
if [ -n "$existing" ]; then
remote_integrity="$(npm view "dingtalk-workspace-cli@$semver" dist.integrity)"
if [ -z "$remote_integrity" ] || [ "$remote_integrity" != "$local_integrity" ]; then
echo "npm $semver integrity differs from the sealed package." >&2
echo "local: $local_integrity" >&2
echo "remote: $remote_integrity" >&2
exit 1
fi
advance_channel=false
if [ "$current" = "$semver" ]; then
:
elif [ -n "$current" ] && release_version_is_greater "v$current" "$RELEASE_VERSION"; then
echo "npm $npm_tag already points to newer v$current; leaving it unchanged."
elif [ -z "$current" ] || release_version_is_greater "$RELEASE_VERSION" "v$current"; then
advance_channel=true
else
echo "Cannot reconcile npm $npm_tag=$current with $RELEASE_VERSION" >&2
exit 1
fi
{
echo "published=true"
echo "advance_channel=$advance_channel"
echo "npm_tag=$npm_tag"
echo "semver=$semver"
} >> "$GITHUB_OUTPUT"
exit 0
fi
if [ -n "$current" ] && ! release_version_is_greater "$RELEASE_VERSION" "v$current"; then
echo "$RELEASE_VERSION would move npm $npm_tag backwards from v$current" >&2
exit 1
fi
{
echo "published=false"
echo "advance_channel=false"
echo "npm_tag=$npm_tag"
echo "semver=$semver"
} >> "$GITHUB_OUTPUT"
- name: Publish missing version to npm channel
if: ${{ github.repository_owner == 'DingTalk-Real-AI' && steps.npm-state.outputs.published != 'true' }}
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
NPM_TAG: ${{ steps.npm-state.outputs.npm_tag }}
SEMVER: ${{ steps.npm-state.outputs.semver }}
run: |
set -eu
. ./scripts/release/release-lib.sh
current="$(npm view dingtalk-workspace-cli "dist-tags.$NPM_TAG")"
if [ -n "$current" ] && [ "$current" != "$SEMVER" ] && ! release_version_is_greater "v$SEMVER" "v$current"; then
echo "Refusing to move npm $NPM_TAG backwards from v$current to v$SEMVER" >&2
exit 1
fi
npm publish "$RUNNER_TEMP/dingtalk-workspace-cli.tgz" \
--access public \
--tag "$NPM_TAG" \
--ignore-scripts \
--registry=https://registry.npmjs.org
- name: Advance npm channel for an existing exact version
if: ${{ github.repository_owner == 'DingTalk-Real-AI' && steps.npm-state.outputs.advance_channel == 'true' }}
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
NPM_TAG: ${{ steps.npm-state.outputs.npm_tag }}
SEMVER: ${{ steps.npm-state.outputs.semver }}
run: |
set -eu
. ./scripts/release/release-lib.sh
current="$(npm view dingtalk-workspace-cli "dist-tags.$NPM_TAG")"
if [ "$current" = "$SEMVER" ]; then exit 0; fi
if [ -n "$current" ] && release_version_is_greater "v$current" "v$SEMVER"; then
echo "npm $NPM_TAG advanced to newer v$current; leaving it unchanged."
exit 0
fi
if [ -n "$current" ] && ! release_version_is_greater "v$SEMVER" "v$current"; then
echo "Refusing unsafe npm $NPM_TAG transition from v$current to v$SEMVER" >&2
exit 1
fi
npm dist-tag add "dingtalk-workspace-cli@$SEMVER" "$NPM_TAG" \
--registry=https://registry.npmjs.org
- name: Verify npm channel delivery
if: ${{ github.repository_owner == 'DingTalk-Real-AI' }}
env:
NPM_TAG: ${{ steps.npm-state.outputs.npm_tag }}
SEMVER: ${{ steps.npm-state.outputs.semver }}
run: |
set -eu
. ./scripts/release/release-lib.sh
attempt=1
max_attempts=60
while :; do
delivered="$(npm view dingtalk-workspace-cli "dist-tags.$NPM_TAG" \
--registry=https://registry.npmjs.org --prefer-online)"
if [ "$delivered" = "$SEMVER" ]; then exit 0; fi
if release_version_is_greater "v$delivered" "v$SEMVER"; then
echo "npm $NPM_TAG already supersedes this historical rerun at v$delivered."
exit 0
fi
release_version_is_greater "v$SEMVER" "v$delivered" || {
echo "npm $NPM_TAG=$delivered cannot be reconciled with v$SEMVER" >&2
exit 1
}
if [ "$attempt" -ge "$max_attempts" ]; then
echo "npm $NPM_TAG still reports older v$delivered after $attempt attempts; expected v$SEMVER" >&2
exit 1
fi
echo "npm $NPM_TAG still reports older v$delivered; waiting for registry propagation ($attempt/$max_attempts)."
# incremental backoff: 5s for first 12 attempts (1 min), then 10s
if [ "$attempt" -lt 12 ]; then
sleep 5
else
sleep 10
fi
attempt=$((attempt + 1))
done
- name: Sync release artifacts to China OSS mirror
if: ${{ needs.release-contract.outputs.oss_mirror == 'enabled' }}
run: ./scripts/release/sync-to-oss.sh
env:
VERSION: ${{ needs.release-contract.outputs.release_version }}
DWS_RELEASE_CHANNEL: ${{ needs.release-contract.outputs.channel }}
OSS_ACCESS_KEY_ID: ${{ secrets.OSS_ACCESS_KEY_ID }}
OSS_ACCESS_KEY_SECRET: ${{ secrets.OSS_ACCESS_KEY_SECRET }}
OSS_ENDPOINT: ${{ secrets.OSS_ENDPOINT }}
OSS_BUCKET: ${{ secrets.OSS_BUCKET }}
OSS_PREFIX: ${{ secrets.OSS_PREFIX }}
DWS_REQUIRE_OSS: "1"
mirror-gitee-release:
name: Mirror immutable release to Gitee
if: ${{ !cancelled() && vars.ENABLE_GITEE_UPLOAD_FALLBACK == 'true' && needs.release-contract.result == 'success' && needs.release.result == 'success' && needs.publish-channels.result == 'success' }}
needs: [release-contract, release, publish-channels]
runs-on: ubuntu-latest
timeout-minutes: 360
permissions:
contents: read
env:
RELEASE_VERSION: ${{ needs.release-contract.outputs.release_version }}
RELEASE_COMMIT: ${{ needs.release-contract.outputs.release_commit }}
RELEASE_TAG_OBJECT: ${{ needs.release-contract.outputs.release_tag_object }}
steps:
- name: Check out sealed release source
uses: actions/checkout@v4
timeout-minutes: 5
with:
ref: ${{ needs.release-contract.outputs.release_commit }}
persist-credentials: false
- name: Check out trusted release tooling
uses: actions/checkout@v4
timeout-minutes: 5
with:
ref: ${{ github.sha }}
path: tmp/trusted-release-tooling
persist-credentials: false
- name: Fetch and verify sealed release tag
timeout-minutes: 5
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
git fetch --force --no-tags \
https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli.git \
'+refs/tags/v*:refs/tags/v*' \
'+refs/tags/withdrawn/v*:refs/tags/withdrawn/v*'
"$GITHUB_WORKSPACE/tmp/trusted-release-tooling/scripts/release/verify-github-tag-authority.sh" \
"$RELEASE_VERSION" "$RELEASE_COMMIT" "$RELEASE_TAG_OBJECT"
- name: Restore finalized distribution files
uses: actions/download-artifact@v4
timeout-minutes: 10
with:
name: finalized-release-dist
path: dist
- name: Mirror release to Gitee (China)
timeout-minutes: 318
run: ./scripts/release/sync-to-gitee.sh
env:
VERSION: ${{ needs.release-contract.outputs.release_version }}
GITEE_TOKEN: ${{ secrets.GITEE_TOKEN }}
GITEE_USER: ${{ secrets.GITEE_USER }}
GITEE_REPO: ${{ secrets.GITEE_REPO }}
DWS_REQUIRE_GITEE: "1"
repair-npm:
needs: dispatch-contract
if: ${{ needs.dispatch-contract.outputs.mode == 'repair_npm' && github.ref_name == github.event.repository.default_branch && github.repository_owner == 'DingTalk-Real-AI' }}
runs-on: ubuntu-latest
timeout-minutes: 20
permissions:
actions: read
contents: read
steps:
- name: Check out trusted release tooling
uses: actions/checkout@v4
with:
ref: ${{ github.sha }}
path: tooling
persist-credentials: false
- name: Validate repair version
id: version
working-directory: tooling
env:
VERSION: ${{ inputs.repair_npm_version }}
run: |
set -eu
. ./scripts/release/release-lib.sh
if ! release_is_stable_version "$VERSION" && ! release_is_prerelease_version "$VERSION"; then
echo "Invalid release version: $VERSION" >&2
exit 2
fi
echo "semver=${VERSION#v}" >> "$GITHUB_OUTPUT"
- name: Verify immutable release authority
id: authority
uses: actions/github-script@v7
env:
VERSION: ${{ inputs.repair_npm_version }}
with:
script: |
const { owner, repo } = context.repo;
const version = process.env.VERSION;
try {
await github.rest.git.getRef({
owner,
repo,
ref: `tags/withdrawn/${version}`,
});
core.setFailed(`${version} was withdrawn and cannot be repaired`);
return;
} catch (error) {
if (error.status !== 404) throw error;
}
const ref = await github.rest.git.getRef({
owner,
repo,
ref: `tags/${version}`,
});
if (ref.data.object.type !== "tag") {
core.setFailed(`${version} is not an annotated tag`);
return;
}
const tag = await github.rest.git.getTag({
owner,
repo,
tag_sha: ref.data.object.sha,
});
if (tag.data.tag !== version || tag.data.object.type !== "commit") {
core.setFailed(`${version} does not resolve to one annotated commit tag`);
return;
}
const commitSha = tag.data.object.sha;
const defaultBranch = context.payload.repository.default_branch;
const branch = await github.rest.repos.getBranch({
owner,
repo,
branch: defaultBranch,
});
const comparison = await github.rest.repos.compareCommitsWithBasehead({
owner,
repo,
basehead: `${commitSha}...${branch.data.commit.sha}`,
});
if (!["ahead", "identical"].includes(comparison.data.status)) {
core.setFailed(`${version} is not contained in ${defaultBranch}`);
return;
}
const release = await github.rest.repos.getReleaseByTag({
owner,
repo,
tag: version,
});
if (
release.data.tag_name !== version ||
release.data.draft ||
!release.data.immutable
) {
core.setFailed(`${version} is not a public immutable GitHub Release`);
return;
}
const expectedAssets = new Set([
"dws-darwin-amd64.tar.gz",
"dws-darwin-arm64.tar.gz",
"dws-linux-amd64.tar.gz",
"dws-linux-arm64.tar.gz",
"dws-windows-amd64.zip",
"dws-windows-arm64.zip",
"dws-skills.zip",
"checksums.txt",
]);
const assetNames = release.data.assets.map((asset) => asset.name);
if (
assetNames.length !== expectedAssets.size ||
assetNames.some((name) => !expectedAssets.has(name)) ||
new Set(assetNames).size !== expectedAssets.size
) {
core.setFailed(`${version} does not contain the exact release asset set`);
return;
}
core.setOutput("commit_sha", commitSha);
- name: Require successful Release workflow delivery
working-directory: tooling
env:
VERSION: ${{ inputs.repair_npm_version }}
RELEASE_COMMIT: ${{ steps.authority.outputs.commit_sha }}
DWS_RELEASE_GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
./scripts/release/verify-release-workflow-delivery.sh --npm-repair \
"$VERSION" "$RELEASE_COMMIT"
- name: Check out target release source
uses: actions/checkout@v4
with:
ref: ${{ steps.authority.outputs.commit_sha }}
path: release-source
persist-credentials: false
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: "20"
registry-url: "https://registry.npmjs.org"
- name: Download and verify GitHub release assets
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
VERSION: ${{ inputs.repair_npm_version }}
run: |
set -eu
mkdir -p dist
gh release download "$VERSION" \
--repo "$GITHUB_REPOSITORY" \
--dir dist \
--pattern 'dws-*' \
--pattern 'checksums.txt' \
--clobber
DWS_PACKAGE_DIST_DIR="$GITHUB_WORKSPACE/dist" \
"$GITHUB_WORKSPACE/tooling/scripts/release/verify-release-artifacts.sh" "$VERSION"
- name: Stage and verify target npm package
env:
VERSION: ${{ inputs.repair_npm_version }}
run: |
set -eu
DWS_PACKAGE_SOURCE_ROOT="$GITHUB_WORKSPACE/release-source" \
DWS_PACKAGE_DIST_DIR="$GITHUB_WORKSPACE/dist" \
"$GITHUB_WORKSPACE/tooling/scripts/release/stage-npm-package.sh" "$VERSION"
DWS_PACKAGE_DIST_DIR="$GITHUB_WORKSPACE/dist" \
"$GITHUB_WORKSPACE/tooling/scripts/release/verify-package-managers.sh" \
--npm-only \
--expected-version "$VERSION" \
--skip-skill-targets
- name: Inspect npm version
id: npm
env:
SEMVER: ${{ steps.version.outputs.semver }}
run: |
set -eu
local_integrity="$(
"$GITHUB_WORKSPACE/tooling/scripts/release/pack-npm-package.sh" \
"$GITHUB_WORKSPACE/dist/npm/dingtalk-workspace-cli" \
"$RUNNER_TEMP/dingtalk-workspace-cli-repair.tgz"
)"
version_error="$RUNNER_TEMP/npm-version-error.log"
if existing="$(npm view "dingtalk-workspace-cli@$SEMVER" version 2>"$version_error")"; then
if [ "$existing" != "$SEMVER" ]; then
echo "npm returned unexpected version $existing for $SEMVER" >&2
exit 1
fi
elif grep -Eq 'E404|404 Not Found' "$version_error"; then
existing=""
else
cat "$version_error" >&2
exit 1
fi
if [ "$existing" = "$SEMVER" ]; then
remote_integrity="$(npm view "dingtalk-workspace-cli@$SEMVER" dist.integrity)"
if [ -z "$remote_integrity" ] || [ "$remote_integrity" != "$local_integrity" ]; then
echo "npm $SEMVER integrity differs from the immutable release package." >&2
echo "local: $local_integrity" >&2
echo "remote: $remote_integrity" >&2
exit 1
fi
echo "published=true" >> "$GITHUB_OUTPUT"
else
echo "published=false" >> "$GITHUB_OUTPUT"
fi
- name: Backfill exact version to npm
if: ${{ steps.npm.outputs.published != 'true' }}
run: npm publish "$RUNNER_TEMP/dingtalk-workspace-cli-repair.tgz" --access public --tag backfill --ignore-scripts --registry=https://registry.npmjs.org
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
coverage-baseline-confirmation:
name: Confirm Formula coverage baseline
# Once Formula sealing has exposed a target SHA, later publication
# verification failures must not orphan its exact-main cache producer.
if: ${{ !cancelled() && (needs.publish-release.result == 'success' || needs.publish-release.outputs.coverage_baseline_required == 'true') }}
needs: publish-release
runs-on: ubuntu-latest
timeout-minutes: 35
permissions:
checks: write
contents: write
steps:
- name: Require exact Formula cache acknowledgement
uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7.1.0
env:
BASELINE_REQUIRED: ${{ needs.publish-release.outputs.coverage_baseline_required }}
FORMULA_COMMIT: ${{ needs.publish-release.outputs.coverage_baseline_commit }}
with:
script: |
const rawRequired = process.env.BASELINE_REQUIRED;
if (!['true', 'false'].includes(rawRequired)) {
throw new Error(`Formula baseline requirement is invalid: ${rawRequired || 'empty'}`);
}
const required = rawRequired === 'true';
const targetSha = process.env.FORMULA_COMMIT;
if (!required) {
if (targetSha) {
throw new Error('Formula baseline outputs are inconsistent for a no-op publication');
}
core.info('Formula was already current; no new exact-SHA cache acknowledgement is required.');
return;
}
if (!/^[0-9a-f]{40}$/.test(targetSha)) {
throw new Error('Formula baseline target output is malformed');
}
const expectedExternalId = `release-${context.runId}-${targetSha}`;
let promotionCheck;
try {
const created = await github.rest.checks.create({
...context.repo,
name: 'Coverage Baseline Cache',
head_sha: targetSha,
status: 'queued',
external_id: expectedExternalId,
output: {
title: 'Waiting for exact-SHA baseline promotion',
summary:
'The independent release governance job is waiting for the default-branch cache producer.',
},
});
promotionCheck = created.data;
await github.rest.repos.createDispatchEvent({
...context.repo,
event_type: 'coverage-baseline-promote',
client_payload: {
target_sha: targetSha,
source_run_id: String(context.runId),
check_run_id: String(promotionCheck.id),
},
});
} catch (error) {
if (promotionCheck) {
try {
await github.rest.checks.update({
...context.repo,
check_run_id: promotionCheck.id,
status: 'completed',
conclusion: 'failure',
completed_at: new Date().toISOString(),
output: {
title: 'Coverage baseline dispatch failed',
summary: `Release could not dispatch the exact-SHA producer: ${error.message}`,
},
});
} catch (cleanupError) {
core.error(
`Could not close failed cache acknowledgement ${promotionCheck.id}: ${cleanupError.message}`,
);
}
}
throw error;
}
const checkRunId = promotionCheck.id;
for (let attempt = 1; attempt <= 180; attempt += 1) {
const {data: currentCheck} = await github.rest.checks.get({
...context.repo,
check_run_id: checkRunId,
});
if (
currentCheck.head_sha !== targetSha ||
currentCheck.name !== 'Coverage Baseline Cache' ||
currentCheck.external_id !== expectedExternalId ||
currentCheck.app?.slug !== 'github-actions'
) {
throw new Error('Formula baseline promotion acknowledgement changed identity');
}
if (currentCheck.status === 'completed') {
if (currentCheck.conclusion !== 'success') {
throw new Error(
`Formula baseline promotion failed with ${currentCheck.conclusion || 'unknown'}`,
);
}
core.info(`Formula baseline promotion completed for ${targetSha}.`);
return;
}
if (attempt < 180) {
await new Promise(resolve => setTimeout(resolve, 10000));
}
}
throw new Error(`Formula baseline promotion timed out for ${targetSha}`);
release-delivery-gate:
name: Release delivery gate
if: ${{ !cancelled() }}
needs:
- dispatch-contract
- governance-preflight
- release-contract
- release-validation
- release
- verify-darwin-signatures
- publish-release
- publish-channels
- coverage-baseline-confirmation
- mirror-gitee-release
- repair-npm
- repair-channel
- release-plan
- seal-release
runs-on: ubuntu-latest
timeout-minutes: 2
permissions: {}
steps:
- name: Require the selected release mode to finish
env:
EVENT_NAME: ${{ github.event_name }}
DISPATCH_MODE: ${{ needs.dispatch-contract.outputs.mode }}
GITEE_FALLBACK_ENABLED: ${{ vars.ENABLE_GITEE_UPLOAD_FALLBACK }}
DISPATCH_RESULT: ${{ needs.dispatch-contract.result }}
GOVERNANCE_PREFLIGHT_RESULT: ${{ needs.governance-preflight.result }}
RELEASE_CONTRACT_RESULT: ${{ needs.release-contract.result }}
RELEASE_VALIDATION_RESULT: ${{ needs.release-validation.result }}
RELEASE_RESULT: ${{ needs.release.result }}
DARWIN_SIGNATURE_RESULT: ${{ needs.verify-darwin-signatures.result }}
PUBLISH_RELEASE_RESULT: ${{ needs.publish-release.result }}
PUBLISH_CHANNELS_RESULT: ${{ needs.publish-channels.result }}
COVERAGE_BASELINE_CONFIRMATION_RESULT: ${{ needs.coverage-baseline-confirmation.result }}
MIRROR_GITEE_RESULT: ${{ needs.mirror-gitee-release.result }}
REPAIR_NPM_RESULT: ${{ needs.repair-npm.result }}
REPAIR_CHANNEL_RESULT: ${{ needs.repair-channel.result }}
RELEASE_PLAN_RESULT: ${{ needs.release-plan.result }}
SEAL_RELEASE_RESULT: ${{ needs.seal-release.result }}
run: |
set -eu
require_result() {
job="$1"
actual="$2"
expected="$3"
test "$actual" = "$expected" || {
echo "$job result is $actual, expected $expected" >&2
exit 1
}
}
require_publication() {
require_result release-contract "$RELEASE_CONTRACT_RESULT" success
require_result release-validation "$RELEASE_VALIDATION_RESULT" success
require_result release "$RELEASE_RESULT" success
require_result verify-darwin-signatures "$DARWIN_SIGNATURE_RESULT" success
require_result publish-release "$PUBLISH_RELEASE_RESULT" success
require_result publish-channels "$PUBLISH_CHANNELS_RESULT" success
require_result coverage-baseline-confirmation "$COVERAGE_BASELINE_CONFIRMATION_RESULT" success
if test "$GITEE_FALLBACK_ENABLED" = true; then
require_result mirror-gitee-release "$MIRROR_GITEE_RESULT" success
else
require_result mirror-gitee-release "$MIRROR_GITEE_RESULT" skipped
fi
}
require_cloud_jobs_skipped() {
require_result release-plan "$RELEASE_PLAN_RESULT" skipped
require_result seal-release "$SEAL_RELEASE_RESULT" skipped
}
case "$EVENT_NAME:$DISPATCH_MODE" in
push:)
require_result dispatch-contract "$DISPATCH_RESULT" skipped
require_result governance-preflight "$GOVERNANCE_PREFLIGHT_RESULT" skipped
require_result repair-npm "$REPAIR_NPM_RESULT" skipped
require_result repair-channel "$REPAIR_CHANNEL_RESULT" skipped
require_cloud_jobs_skipped
require_publication
;;
workflow_dispatch:create_release)
require_result dispatch-contract "$DISPATCH_RESULT" success
require_result governance-preflight "$GOVERNANCE_PREFLIGHT_RESULT" success
require_result release-plan "$RELEASE_PLAN_RESULT" success
require_result seal-release "$SEAL_RELEASE_RESULT" success
require_result repair-npm "$REPAIR_NPM_RESULT" skipped
require_result repair-channel "$REPAIR_CHANNEL_RESULT" skipped
require_publication
;;
workflow_dispatch:plan_release)
require_result dispatch-contract "$DISPATCH_RESULT" success
require_result release-plan "$RELEASE_PLAN_RESULT" success
require_result seal-release "$SEAL_RELEASE_RESULT" skipped
require_result governance-preflight "$GOVERNANCE_PREFLIGHT_RESULT" skipped
require_result release-contract "$RELEASE_CONTRACT_RESULT" skipped
require_result release-validation "$RELEASE_VALIDATION_RESULT" skipped
require_result release "$RELEASE_RESULT" skipped
require_result verify-darwin-signatures "$DARWIN_SIGNATURE_RESULT" skipped
require_result publish-release "$PUBLISH_RELEASE_RESULT" skipped
require_result publish-channels "$PUBLISH_CHANNELS_RESULT" skipped
require_result coverage-baseline-confirmation "$COVERAGE_BASELINE_CONFIRMATION_RESULT" skipped
require_result mirror-gitee-release "$MIRROR_GITEE_RESULT" skipped
require_result repair-npm "$REPAIR_NPM_RESULT" skipped
require_result repair-channel "$REPAIR_CHANNEL_RESULT" skipped
;;
workflow_dispatch:recover_release)
require_result dispatch-contract "$DISPATCH_RESULT" success
require_result governance-preflight "$GOVERNANCE_PREFLIGHT_RESULT" skipped
require_result repair-npm "$REPAIR_NPM_RESULT" skipped
require_result repair-channel "$REPAIR_CHANNEL_RESULT" skipped
require_cloud_jobs_skipped
require_publication
;;
workflow_dispatch:governance_preflight)
require_result dispatch-contract "$DISPATCH_RESULT" success
require_result governance-preflight "$GOVERNANCE_PREFLIGHT_RESULT" success
require_result release-contract "$RELEASE_CONTRACT_RESULT" skipped
require_result release-validation "$RELEASE_VALIDATION_RESULT" skipped
require_result release "$RELEASE_RESULT" skipped
require_result verify-darwin-signatures "$DARWIN_SIGNATURE_RESULT" skipped
require_result publish-release "$PUBLISH_RELEASE_RESULT" skipped
require_result publish-channels "$PUBLISH_CHANNELS_RESULT" skipped
require_result coverage-baseline-confirmation "$COVERAGE_BASELINE_CONFIRMATION_RESULT" skipped
require_result mirror-gitee-release "$MIRROR_GITEE_RESULT" skipped
require_result repair-npm "$REPAIR_NPM_RESULT" skipped
require_result repair-channel "$REPAIR_CHANNEL_RESULT" skipped
require_cloud_jobs_skipped
;;
workflow_dispatch:repair_npm)
require_result dispatch-contract "$DISPATCH_RESULT" success
require_result repair-npm "$REPAIR_NPM_RESULT" success
require_result governance-preflight "$GOVERNANCE_PREFLIGHT_RESULT" skipped
require_result release-contract "$RELEASE_CONTRACT_RESULT" skipped
require_result release-validation "$RELEASE_VALIDATION_RESULT" skipped
require_result release "$RELEASE_RESULT" skipped
require_result verify-darwin-signatures "$DARWIN_SIGNATURE_RESULT" skipped
require_result publish-release "$PUBLISH_RELEASE_RESULT" skipped
require_result publish-channels "$PUBLISH_CHANNELS_RESULT" skipped
require_result coverage-baseline-confirmation "$COVERAGE_BASELINE_CONFIRMATION_RESULT" skipped
require_result mirror-gitee-release "$MIRROR_GITEE_RESULT" skipped
require_result repair-channel "$REPAIR_CHANNEL_RESULT" skipped
require_cloud_jobs_skipped
;;
workflow_dispatch:repair_gitee)
require_result dispatch-contract "$DISPATCH_RESULT" success
require_result repair-channel "$REPAIR_CHANNEL_RESULT" success
require_result governance-preflight "$GOVERNANCE_PREFLIGHT_RESULT" skipped
require_result release-contract "$RELEASE_CONTRACT_RESULT" skipped
require_result release-validation "$RELEASE_VALIDATION_RESULT" skipped
require_result release "$RELEASE_RESULT" skipped
require_result verify-darwin-signatures "$DARWIN_SIGNATURE_RESULT" skipped
require_result publish-release "$PUBLISH_RELEASE_RESULT" skipped
require_result publish-channels "$PUBLISH_CHANNELS_RESULT" skipped
require_result coverage-baseline-confirmation "$COVERAGE_BASELINE_CONFIRMATION_RESULT" skipped
require_result mirror-gitee-release "$MIRROR_GITEE_RESULT" skipped
require_result repair-npm "$REPAIR_NPM_RESULT" skipped
require_cloud_jobs_skipped
;;
workflow_dispatch:repair_oss)
require_result dispatch-contract "$DISPATCH_RESULT" success
require_result repair-channel "$REPAIR_CHANNEL_RESULT" success
require_result governance-preflight "$GOVERNANCE_PREFLIGHT_RESULT" skipped
require_result release-contract "$RELEASE_CONTRACT_RESULT" skipped
require_result release-validation "$RELEASE_VALIDATION_RESULT" skipped
require_result release "$RELEASE_RESULT" skipped
require_result verify-darwin-signatures "$DARWIN_SIGNATURE_RESULT" skipped
require_result publish-release "$PUBLISH_RELEASE_RESULT" skipped
require_result publish-channels "$PUBLISH_CHANNELS_RESULT" skipped
require_result coverage-baseline-confirmation "$COVERAGE_BASELINE_CONFIRMATION_RESULT" skipped
require_result mirror-gitee-release "$MIRROR_GITEE_RESULT" skipped
require_result repair-npm "$REPAIR_NPM_RESULT" skipped
require_cloud_jobs_skipped
;;
*)
echo "unsupported release mode: event=$EVENT_NAME mode=$DISPATCH_MODE" >&2
exit 1
;;
esac
# Repair one downstream channel from an existing public immutable release on
# demand. The push-triggered channel jobs depend on the original run's
# finalized dist artifact, so a tag that was already published cannot reuse
# it. This dispatch path re-derives the asset set from the immutable GitHub
# Release itself. The release tag, delivery provenance, and asset checksums
# remain the sealed authority boundary.
repair-channel:
name: Repair an existing immutable release channel
needs: dispatch-contract
if: ${{ !cancelled() && needs.dispatch-contract.result == 'success' && (needs.dispatch-contract.outputs.mode == 'repair_gitee' || needs.dispatch-contract.outputs.mode == 'repair_oss') && github.ref == format('refs/heads/{0}', github.event.repository.default_branch) && github.repository == 'DingTalk-Real-AI/dingtalk-workspace-cli' }}
runs-on: ubuntu-latest
timeout-minutes: 360
permissions:
actions: read
contents: read
steps:
- name: Check out trusted release tooling
uses: actions/checkout@v4
timeout-minutes: 5
with:
ref: ${{ github.sha }}
path: tooling
persist-credentials: false
fetch-depth: 0
- name: Validate repair version
timeout-minutes: 2
working-directory: tooling
env:
VERSION: ${{ inputs.repair_gitee_version || inputs.repair_oss_version }}
run: |
set -eu
. ./scripts/release/release-lib.sh
if ! release_is_stable_version "$VERSION" && ! release_is_prerelease_version "$VERSION"; then
echo "Invalid release version: $VERSION" >&2
exit 2
fi
- name: Verify immutable release authority
id: authority
uses: actions/github-script@v7
timeout-minutes: 5
env:
VERSION: ${{ inputs.repair_gitee_version || inputs.repair_oss_version }}
with:
script: |
const { owner, repo } = context.repo;
const version = process.env.VERSION;
try {
await github.rest.git.getRef({
owner,
repo,
ref: `tags/withdrawn/${version}`,
});
core.setFailed(`${version} was withdrawn and cannot be repaired`);
return;
} catch (error) {
if (error.status !== 404) throw error;
}
const ref = await github.rest.git.getRef({
owner,
repo,
ref: `tags/${version}`,
});
if (ref.data.object.type !== "tag") {
core.setFailed(`${version} is not an annotated tag`);
return;
}
const tag = await github.rest.git.getTag({
owner,
repo,
tag_sha: ref.data.object.sha,
});
if (tag.data.tag !== version || tag.data.object.type !== "commit") {
core.setFailed(`${version} does not resolve to one annotated commit tag`);
return;
}
const tagFields = new Map();
for (const line of tag.data.message.split(/\r?\n/)) {
const separator = line.indexOf(": ");
if (separator < 1) continue;
const key = line.slice(0, separator);
if (tagFields.has(key)) {
core.setFailed(`${version} annotated tag contains duplicate ${key} metadata`);
return;
}
tagFields.set(key, line.slice(separator + 2));
}
const ossMirror = tagFields.has("OSS-Mirror")
? tagFields.get("OSS-Mirror")
: "enabled";
if (!["enabled", "deferred"].includes(ossMirror)) {
core.setFailed(`${version} contains invalid OSS-Mirror metadata`);
return;
}
const commitSha = tag.data.object.sha;
const defaultBranch = context.payload.repository.default_branch;
const branch = await github.rest.repos.getBranch({
owner,
repo,
branch: defaultBranch,
});
const comparison = await github.rest.repos.compareCommitsWithBasehead({
owner,
repo,
basehead: `${commitSha}...${branch.data.commit.sha}`,
});
if (!["ahead", "identical"].includes(comparison.data.status)) {
core.setFailed(`${version} is not contained in ${defaultBranch}`);
return;
}
const release = await github.rest.repos.getReleaseByTag({
owner,
repo,
tag: version,
});
if (
release.data.tag_name !== version ||
release.data.draft ||
!release.data.immutable
) {
core.setFailed(`${version} is not a public immutable GitHub Release`);
return;
}
const expectedPrerelease = version.includes("-beta.");
if (release.data.prerelease !== expectedPrerelease) {
core.setFailed(`${version} prerelease metadata does not match its version`);
return;
}
const expectedAssets = new Set([
"dws-darwin-amd64.tar.gz",
"dws-darwin-arm64.tar.gz",
"dws-linux-amd64.tar.gz",
"dws-linux-arm64.tar.gz",
"dws-windows-amd64.zip",
"dws-windows-arm64.zip",
"dws-skills.zip",
"checksums.txt",
]);
const assetNames = release.data.assets.map((asset) => asset.name);
if (
assetNames.length !== expectedAssets.size ||
assetNames.some((name) => !expectedAssets.has(name)) ||
new Set(assetNames).size !== expectedAssets.size
) {
core.setFailed(`${version} does not contain the exact release asset set`);
return;
}
core.setOutput("commit_sha", commitSha);
core.setOutput("tag_object", ref.data.object.sha);
core.setOutput("oss_mirror", ossMirror);
- name: Require sealed OSS policy for repair
if: ${{ needs.dispatch-contract.outputs.mode == 'repair_oss' }}
env:
OSS_MIRROR: ${{ steps.authority.outputs.oss_mirror }}
run: |
test "$OSS_MIRROR" = enabled || {
echo "OSS repair is unavailable because this immutable release deferred the OSS channel." >&2
exit 1
}
- name: Check out sealed release source
uses: actions/checkout@v4
timeout-minutes: 5
with:
ref: ${{ steps.authority.outputs.commit_sha }}
path: release-source
persist-credentials: false
- name: Fetch and verify sealed release tag
timeout-minutes: 5
working-directory: tooling
env:
VERSION: ${{ inputs.repair_gitee_version || inputs.repair_oss_version }}
RELEASE_COMMIT: ${{ steps.authority.outputs.commit_sha }}
RELEASE_TAG_OBJECT: ${{ steps.authority.outputs.tag_object }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
set -eu
git fetch --force --no-tags \
https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli.git \
'+refs/tags/v*:refs/tags/v*' \
'+refs/tags/withdrawn/v*:refs/tags/withdrawn/v*'
./scripts/release/verify-github-tag-authority.sh \
"$VERSION" "$RELEASE_COMMIT" "$RELEASE_TAG_OBJECT"
- name: Require successful Release workflow delivery
timeout-minutes: 5
working-directory: tooling
env:
VERSION: ${{ inputs.repair_gitee_version || inputs.repair_oss_version }}
RELEASE_COMMIT: ${{ steps.authority.outputs.commit_sha }}
REPAIR_MODE: ${{ needs.dispatch-contract.outputs.mode }}
DWS_RELEASE_GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
case "$REPAIR_MODE" in
repair_gitee) target=gitee ;;
repair_oss) target=oss ;;
*) echo "Unsupported channel repair mode: $REPAIR_MODE" >&2; exit 2 ;;
esac
./scripts/release/verify-release-workflow-delivery.sh \
--channel-repair "$target" "$VERSION" "$RELEASE_COMMIT"
- name: Download and verify immutable GitHub Release assets
timeout-minutes: 10
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
VERSION: ${{ inputs.repair_gitee_version || inputs.repair_oss_version }}
run: |
set -eu
mkdir -p dist
gh release download "$VERSION" \
--repo "$GITHUB_REPOSITORY" \
--dir dist \
--pattern 'dws-*' \
--pattern 'checksums.txt' \
--clobber
DWS_PACKAGE_DIST_DIR="$GITHUB_WORKSPACE/dist" \
"$GITHUB_WORKSPACE/tooling/scripts/release/verify-release-artifacts.sh" "$VERSION"
- name: Mirror release to Gitee (China)
if: ${{ needs.dispatch-contract.outputs.mode == 'repair_gitee' }}
timeout-minutes: 318
working-directory: tooling
run: |
"$GITHUB_WORKSPACE/tooling/scripts/release/sync-to-gitee.sh"
env:
VERSION: ${{ inputs.repair_gitee_version || inputs.repair_oss_version }}
DIST_DIR: ${{ github.workspace }}/dist
GITEE_TOKEN: ${{ secrets.GITEE_TOKEN }}
GITEE_USER: ${{ secrets.GITEE_USER }}
GITEE_REPO: ${{ secrets.GITEE_REPO }}
DWS_REQUIRE_GITEE: "1"
- name: Mirror release to OSS (China)
if: ${{ needs.dispatch-contract.outputs.mode == 'repair_oss' }}
timeout-minutes: 30
working-directory: release-source
run: |
"$GITHUB_WORKSPACE/tooling/scripts/release/sync-to-oss.sh"
env:
VERSION: ${{ inputs.repair_gitee_version || inputs.repair_oss_version }}
DIST_DIR: ${{ github.workspace }}/dist
OSS_ACCESS_KEY_ID: ${{ secrets.OSS_ACCESS_KEY_ID }}
OSS_ACCESS_KEY_SECRET: ${{ secrets.OSS_ACCESS_KEY_SECRET }}
OSS_ENDPOINT: ${{ secrets.OSS_ENDPOINT }}
OSS_BUCKET: ${{ secrets.OSS_BUCKET }}
OSS_PREFIX: ${{ secrets.OSS_PREFIX }}
DWS_REQUIRE_OSS: "1"
# These checks remain publication blockers, but they do not need to serialize
# artifact compilation. Each matrix leg gets an isolated runner workspace so the
# module-tidy check and E2E build cache cannot interfere with one another.
release-validation:
name: Validate sealed release (${{ matrix.check }})
if: ${{ !cancelled() && needs.release-contract.result == 'success' }}
needs: [release-contract]
runs-on: ubuntu-latest
timeout-minutes: 15
permissions:
contents: read
strategy:
fail-fast: false
matrix:
check:
- automation
- compatibility
- e2e
steps:
- name: Check out sealed release source
uses: actions/checkout@v4
with:
ref: ${{ needs.release-contract.outputs.release_commit }}
persist-credentials: false
fetch-depth: 0
- name: Check out trusted release tooling
uses: actions/checkout@v4
with:
ref: ${{ github.sha }}
path: tmp/trusted-release-tooling
persist-credentials: false
- name: Fetch and verify sealed release tag
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
RELEASE_VERSION: ${{ needs.release-contract.outputs.release_version }}
RELEASE_COMMIT: ${{ needs.release-contract.outputs.release_commit }}
RELEASE_TAG_OBJECT: ${{ needs.release-contract.outputs.release_tag_object }}
run: |
set -eu
git fetch --force --no-tags \
https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli.git \
'+refs/tags/v*:refs/tags/v*' \
'+refs/tags/withdrawn/v*:refs/tags/withdrawn/v*'
"$GITHUB_WORKSPACE/tmp/trusted-release-tooling/scripts/release/verify-github-tag-authority.sh" \
"$RELEASE_VERSION" "$RELEASE_COMMIT" "$RELEASE_TAG_OBJECT"
# The sealed candidate tag is intentionally visible while its GitHub
# authority is checked above. Compatibility must instead discover the
# previous delivered stable tag, so hide only this verified candidate
# from this isolated runner's local tag namespace.
- name: Prepare delivered-stable compatibility ref view
if: ${{ matrix.check == 'compatibility' }}
env:
RELEASE_VERSION: ${{ needs.release-contract.outputs.release_version }}
RELEASE_COMMIT: ${{ needs.release-contract.outputs.release_commit }}
RELEASE_TAG_OBJECT: ${{ needs.release-contract.outputs.release_tag_object }}
PREVIOUS_STABLE: ${{ needs.release-contract.outputs.previous_stable }}
PREVIOUS_STABLE_COMMIT: ${{ needs.release-contract.outputs.previous_stable_commit }}
run: |
set -eu
test -n "$RELEASE_VERSION"
test -n "$RELEASE_COMMIT"
test -n "$RELEASE_TAG_OBJECT"
test -n "$PREVIOUS_STABLE"
test -n "$PREVIOUS_STABLE_COMMIT"
test "$RELEASE_VERSION" != "$PREVIOUS_STABLE"
test "$(git rev-parse HEAD)" = "$RELEASE_COMMIT"
test "$(git rev-parse --verify "refs/tags/${RELEASE_VERSION}")" = "$RELEASE_TAG_OBJECT"
test "$(git rev-parse --verify "refs/tags/${RELEASE_VERSION}^{commit}")" = "$RELEASE_COMMIT"
test "$(git rev-parse --verify "${PREVIOUS_STABLE}^{commit}")" = "$PREVIOUS_STABLE_COMMIT"
git update-ref -d "refs/tags/${RELEASE_VERSION}" "$RELEASE_TAG_OBJECT"
if git show-ref --verify --quiet "refs/tags/${RELEASE_VERSION}"; then
echo "sealed candidate tag is still visible to compatibility baseline discovery" >&2
exit 2
fi
test "$(git rev-parse HEAD)" = "$RELEASE_COMMIT"
test "$(git rev-parse --verify "${PREVIOUS_STABLE}^{commit}")" = "$PREVIOUS_STABLE_COMMIT"
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version-file: go.mod
- name: Run isolated release validation
env:
VALIDATION_CHECK: ${{ matrix.check }}
PREVIOUS_STABLE: ${{ needs.release-contract.outputs.previous_stable }}
run: |
set -eu
case "$VALIDATION_CHECK" in
automation)
go test -v -count=1 -timeout=5m ./test/scripts/... -run '^TestRelease'
go mod tidy
git diff --exit-code -- go.mod go.sum
;;
compatibility)
test -n "$PREVIOUS_STABLE"
"$GITHUB_WORKSPACE/tmp/trusted-release-tooling/scripts/release/check-release-compatibility.sh" \
--repo-root "$GITHUB_WORKSPACE" \
--base-ref HEAD \
--stable-ref "$PREVIOUS_STABLE" \
--candidate-ref HEAD
;;
e2e)
bash scripts/dev/test-multi-profile-e2e.sh
;;
*)
echo "unsupported release validation check: $VALIDATION_CHECK" >&2
exit 2
;;
esac
release-plan:
name: Plan next cloud release
needs: [dispatch-contract]
if: ${{ !cancelled() && needs.dispatch-contract.result == 'success' && (needs.dispatch-contract.outputs.mode == 'plan_release' || needs.dispatch-contract.outputs.mode == 'create_release') }}
runs-on: ubuntu-latest
timeout-minutes: 20
permissions:
actions: read
contents: read
outputs:
release_version: ${{ steps.allocate.outputs.release_version }}
release_commit: ${{ steps.allocate.outputs.release_commit }}
channel: ${{ steps.allocate.outputs.channel }}
from_beta: ${{ steps.allocate.outputs.from_beta }}
base: ${{ steps.allocate.outputs.base }}
refs_fingerprint: ${{ steps.allocate.outputs.refs_fingerprint }}
oss_mirror: ${{ steps.allocate.outputs.oss_mirror }}
previous_stable: ${{ steps.candidate.outputs.previous_stable }}
previous_stable_commit: ${{ steps.candidate.outputs.previous_stable_commit }}
from_beta_commit: ${{ steps.candidate.outputs.from_beta_commit }}
steps:
- name: Validate official default-branch release request
env:
DEFAULT_BRANCH: ${{ github.event.repository.default_branch }}
EXPECTED_REPOSITORY: DingTalk-Real-AI/dingtalk-workspace-cli
run: |
set -eu
test "$GITHUB_REPOSITORY" = "$EXPECTED_REPOSITORY" || {
echo "cloud releases are restricted to $EXPECTED_REPOSITORY" >&2
exit 1
}
test "$GITHUB_REF" = "refs/heads/$DEFAULT_BRANCH" || {
echo "cloud releases must be dispatched from $DEFAULT_BRANCH" >&2
exit 1
}
printf '%s\n' "$GITHUB_SHA" | grep -Eq '^[0-9a-f]{40}$' || {
echo "cloud release commit must be a full lowercase SHA" >&2
exit 1
}
- name: Check out exact default-branch commit
uses: actions/checkout@v4
with:
ref: ${{ github.sha }}
persist-credentials: false
fetch-depth: 0
- name: Fetch current main and all allocated versions
run: |
set -eu
git fetch --force origin \
'+refs/heads/main:refs/remotes/origin/main'
git fetch --force --no-tags \
https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli.git \
'+refs/tags/v*:refs/tags/v*' \
'+refs/tags/withdrawn/v*:refs/tags/withdrawn/v*'
test "$(git rev-parse HEAD)" = "$GITHUB_SHA" || {
echo "checked-out release commit differs from the dispatched commit" >&2
exit 1
}
test "$(git rev-parse refs/remotes/origin/main)" = "$GITHUB_SHA" || {
echo "main moved after this release was dispatched; run it again from current main" >&2
exit 1
}
- name: Allocate the next version
id: allocate
env:
REQUESTED_CHANNEL: ${{ inputs.release_channel }}
REQUESTED_BUMP: ${{ inputs.release_bump }}
OSS_MIRROR: ${{ vars.ENABLE_OSS_MIRROR == 'true' && 'enabled' || 'deferred' }}
run: |
set -eu
allocation="$RUNNER_TEMP/next-release-version"
./scripts/release/next-release-version.sh \
--channel "$REQUESTED_CHANNEL" \
--bump "$REQUESTED_BUMP" > "$allocation"
for key in release_version from_beta channel base; do
count="$(grep -c "^$key=" "$allocation")"
test "$count" -eq 1 || {
echo "allocator returned $count values for $key" >&2
exit 1
}
value="$(sed -n "s/^$key=//p" "$allocation")"
printf '%s=%s\n' "$key" "$value" >> "$GITHUB_OUTPUT"
done
refs_unsorted="$RUNNER_TEMP/release-refs.unsorted"
refs_manifest="$RUNNER_TEMP/release-refs"
git for-each-ref --format='%(refname)=%(objectname)' \
'refs/tags/v*' 'refs/tags/withdrawn/v*' > "$refs_unsorted"
LC_ALL=C sort "$refs_unsorted" > "$refs_manifest"
test -s "$refs_manifest" || {
echo "release ref manifest is empty after fetching allocated tags" >&2
exit 1
}
refs_fingerprint="$(sha256sum "$refs_manifest" | awk '{print $1}')"
echo "release_commit=$GITHUB_SHA" >> "$GITHUB_OUTPUT"
echo "refs_fingerprint=$refs_fingerprint" >> "$GITHUB_OUTPUT"
case "$OSS_MIRROR" in enabled|deferred) ;; *) exit 1 ;; esac
echo "oss_mirror=$OSS_MIRROR" >> "$GITHUB_OUTPUT"
release_version="$(sed -n 's/^release_version=//p' "$allocation")"
from_beta="$(sed -n 's/^from_beta=//p' "$allocation")"
channel="$(sed -n 's/^channel=//p' "$allocation")"
{
echo "## Cloud release plan"
echo
echo "- Version: \`$release_version\`"
echo "- Channel: \`$channel\`"
echo "- Commit: \`$GITHUB_SHA\`"
echo "- OSS mirror: \`$OSS_MIRROR\`"
if test -n "$from_beta"; then
echo "- Promotes beta: \`$from_beta\`"
fi
if test "${{ needs.dispatch-contract.outputs.mode }}" = plan_release; then
echo
echo "Plan only: no tag or package was created. Render pending \`.changes/*.md\` fragments into the exact \`CHANGELOG.md\` section, merge the release-seal PR to main, then run publish."
fi
} >> "$GITHUB_STEP_SUMMARY"
- name: Validate the candidate release contract before sealing
id: candidate
if: ${{ needs.dispatch-contract.outputs.mode == 'create_release' }}
env:
RELEASE_VERSION: ${{ steps.allocate.outputs.release_version }}
RELEASE_CHANNEL: ${{ steps.allocate.outputs.channel }}
FROM_BETA: ${{ steps.allocate.outputs.from_beta }}
OSS_MIRROR: ${{ steps.allocate.outputs.oss_mirror }}
run: |
set -eu
git config user.name "dws release cloud seal"
git config user.email "dws-release@users.noreply.github.com"
{
printf 'Release %s\n\n' "$RELEASE_VERSION"
printf 'Channel: %s\n' "$RELEASE_CHANNEL"
test -z "$FROM_BETA" || printf 'From-Beta: %s\n' "$FROM_BETA"
printf 'OSS-Mirror: %s\n' "$OSS_MIRROR"
} > "$RUNNER_TEMP/candidate-tag-message"
git tag -a "$RELEASE_VERSION" HEAD -F "$RUNNER_TEMP/candidate-tag-message"
set -- \
--channel "$RELEASE_CHANNEL" \
--version "$RELEASE_VERSION" \
--context ci \
--remote origin \
--branch main \
--notes-output "$RUNNER_TEMP/release-notes.md" \
--metadata-output "$RUNNER_TEMP/release-contract-output"
if test -n "$FROM_BETA"; then
set -- "$@" --from-beta "$FROM_BETA"
fi
./scripts/release/release-contract.sh "$@"
for key in previous_stable previous_stable_commit from_beta_commit; do
count="$(grep -c "^$key=" "$RUNNER_TEMP/release-contract-output")"
test "$count" -eq 1 || {
echo "release contract returned $count values for $key" >&2
exit 1
}
value="$(sed -n "s/^$key=//p" "$RUNNER_TEMP/release-contract-output")"
printf '%s=%s\n' "$key" "$value" >> "$GITHUB_OUTPUT"
done
- name: Require delivered previous stable baseline before sealing
if: ${{ needs.dispatch-contract.outputs.mode == 'create_release' }}
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
DWS_RELEASE_GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
set -eu
previous_stable="$(sed -n 's/^previous_stable=//p' "$RUNNER_TEMP/release-contract-output")"
previous_commit="$(sed -n 's/^previous_stable_commit=//p' "$RUNNER_TEMP/release-contract-output")"
if test -n "$previous_stable"; then
./scripts/release/verify-delivered-stable.sh "$previous_stable" "$previous_commit"
fi
- name: Require delivered beta before sealing stable
if: ${{ needs.dispatch-contract.outputs.mode == 'create_release' && steps.allocate.outputs.channel == 'stable' }}
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
DWS_RELEASE_GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
FROM_BETA: ${{ steps.allocate.outputs.from_beta }}
run: |
set -eu
from_beta_commit="$(git rev-parse "$FROM_BETA^{commit}")"
release_state="$(
gh api -H 'Accept: application/vnd.github+json' \
"repos/$GITHUB_REPOSITORY/releases/tags/$FROM_BETA" \
--jq '[.tag_name, .draft, .prerelease, .immutable] | @tsv'
)"
test "$release_state" = "$(printf '%s\tfalse\ttrue\ttrue' "$FROM_BETA")" || {
echo "$FROM_BETA is not a published immutable prerelease" >&2
exit 1
}
./scripts/release/verify-github-release-assets.sh "$FROM_BETA"
./scripts/release/verify-release-workflow-delivery.sh \
"$FROM_BETA" "$from_beta_commit"
seal-release:
name: Seal cloud release tag
needs: [dispatch-contract, governance-preflight, release-plan]
if: ${{ !cancelled() && needs.dispatch-contract.result == 'success' && needs.dispatch-contract.outputs.mode == 'create_release' && needs.governance-preflight.result == 'success' && needs.release-plan.result == 'success' }}
environment:
name: ${{ inputs.release_channel == 'stable' && 'release-stable' || 'release-beta' }}
runs-on: ubuntu-latest
timeout-minutes: 5
permissions:
actions: read
contents: write
outputs:
release_version: ${{ steps.seal.outputs.release_version }}
release_commit: ${{ steps.seal.outputs.release_commit }}
release_tag_object: ${{ steps.seal.outputs.release_tag_object }}
steps:
- name: Verify release environment governance before sealing
uses: actions/github-script@v7
env:
RELEASE_ENVIRONMENT: ${{ inputs.release_channel == 'stable' && 'release-stable' || 'release-beta' }}
with:
script: |
const { owner, repo } = context.repo;
const environmentName = process.env.RELEASE_ENVIRONMENT;
const expectedRepository = "DingTalk-Real-AI/dingtalk-workspace-cli";
if (
context.payload.repository.full_name !== expectedRepository ||
!["release-beta", "release-stable"].includes(environmentName)
) {
core.setFailed("release environment validation is not bound to an official channel");
return;
}
const response = await github.request(
"GET /repos/{owner}/{repo}/environments/{environment_name}",
{ owner, repo, environment_name: environmentName },
);
const environment = response.data;
const branchPolicy = environment.deployment_branch_policy;
const protectionRules = environment.protection_rules || [];
const reviewerRules = protectionRules.filter(
(rule) => rule.type === "required_reviewers",
);
const waitRules = protectionRules.filter(
(rule) => rule.type === "wait_timer",
);
if (
environment.name !== environmentName ||
environment.can_admins_bypass !== false ||
branchPolicy?.protected_branches !== true ||
branchPolicy?.custom_branch_policies !== false ||
waitRules.length !== 0
) {
core.setFailed(`${environmentName} does not match the required release policy`);
return;
}
if (environmentName === "release-beta") {
if (reviewerRules.length !== 0) {
core.setFailed("release-beta must not require a manual reviewer");
}
return;
}
if (
reviewerRules.length !== 1 ||
reviewerRules[0].prevent_self_review !== true ||
!Array.isArray(reviewerRules[0].reviewers) ||
reviewerRules[0].reviewers.length === 0
) {
core.setFailed(
"release-stable requires a non-self reviewing administrator",
);
return;
}
for (const reviewer of reviewerRules[0].reviewers) {
if (
reviewer.type !== "User" ||
!reviewer.reviewer?.login
) {
core.setFailed("release-stable reviewers must be repository administrators");
return;
}
const permission = await github.rest.repos.getCollaboratorPermissionLevel({
owner,
repo,
username: reviewer.reviewer.login,
});
if (permission.data.permission !== "admin") {
core.setFailed(
`${reviewer.reviewer.login} is no longer a repository administrator`,
);
return;
}
}
- name: Create one immutable annotated release tag
id: seal
uses: actions/github-script@v7
env:
RELEASE_VERSION: ${{ needs.release-plan.outputs.release_version }}
RELEASE_COMMIT: ${{ needs.release-plan.outputs.release_commit }}
RELEASE_CHANNEL: ${{ needs.release-plan.outputs.channel }}
FROM_BETA: ${{ needs.release-plan.outputs.from_beta }}
REFS_FINGERPRINT: ${{ needs.release-plan.outputs.refs_fingerprint }}
OSS_MIRROR: ${{ needs.release-plan.outputs.oss_mirror }}
with:
script: |
const crypto = require("crypto");
const { owner, repo } = context.repo;
const expectedRepository = "DingTalk-Real-AI/dingtalk-workspace-cli";
const defaultBranch = context.payload.repository.default_branch;
const version = process.env.RELEASE_VERSION;
const commit = process.env.RELEASE_COMMIT;
const channel = process.env.RELEASE_CHANNEL;
const fromBeta = process.env.FROM_BETA;
const expectedFingerprint = process.env.REFS_FINGERPRINT;
const ossMirror = process.env.OSS_MIRROR;
if (
context.payload.repository.full_name !== expectedRepository ||
context.ref !== `refs/heads/${defaultBranch}` ||
context.sha !== commit
) {
core.setFailed("cloud release seal is not bound to the official dispatched default-branch commit");
return;
}
if (
!/^v(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)(?:-beta\.[1-9]\d*)?$/.test(version) ||
!/^[0-9a-f]{40}$/.test(commit) ||
!/^[0-9a-f]{64}$/.test(expectedFingerprint) ||
!["enabled", "deferred"].includes(ossMirror)
) {
core.setFailed("cloud release plan contains an invalid sealed identity");
return;
}
if (
(channel === "stable" && !fromBeta) ||
(channel === "prerelease" && fromBeta)
) {
core.setFailed("cloud release channel and beta promotion baseline disagree");
return;
}
const branch = await github.rest.repos.getBranch({
owner,
repo,
branch: defaultBranch,
});
const currentBranchCommit = branch.data.commit.sha;
const branchMoved = branch.data.commit.sha !== commit;
const currentAttempt = Number(process.env.GITHUB_RUN_ATTEMPT);
const requestedById = String(context.payload.sender?.id || "");
if (!Number.isSafeInteger(currentAttempt) || currentAttempt < 1 || !/^[1-9]\d*$/.test(requestedById)) {
core.setFailed("cloud release request contains an invalid run attempt or requester identity");
return;
}
const messageForAttempt = (attempt) => [
`Release ${version}`,
"",
`Channel: ${channel}`,
...(fromBeta ? [`From-Beta: ${fromBeta}`] : []),
`OSS-Mirror: ${ossMirror}`,
`Release-Run: ${context.runId}`,
`Release-Run-Attempt: ${attempt}`,
`Requested-By: ${context.actor}`,
`Requested-By-ID: ${context.payload.sender?.id || ""}`,
`Sealed-Commit: ${commit}`,
`Workflow-Commit: ${context.sha}`,
`Allocation-Fingerprint: ${expectedFingerprint}`,
].join("\n");
const retryablePostWriteError = (error) =>
error.status === 404 ||
error.status === 429 ||
(
Number.isInteger(error.status) &&
error.status >= 500 &&
error.status < 600
);
const postWriteRetryDelaysMs = [0, 1000, 2000, 4000, 8000, 16000];
const sleep = (delayMs) =>
new Promise((resolve) => setTimeout(resolve, delayMs));
const readAfterWrite = async (description, operation) => {
let lastError;
for (const delayMs of postWriteRetryDelaysMs) {
if (delayMs > 0) {
core.info(`${description} is not visible yet; retrying in ${delayMs}ms`);
await sleep(delayMs);
}
try {
return await operation();
} catch (error) {
lastError = error;
if (!retryablePostWriteError(error)) {
throw error;
}
}
}
throw lastError;
};
const verifyExactTag = async (refResponse, expectedTagObject = "") => {
if (refResponse.data.object.type !== "tag") {
throw new Error(`${version} does not point to an annotated tag object`);
}
const tagObject = refResponse.data.object.sha;
if (expectedTagObject && tagObject !== expectedTagObject) {
throw new Error(
`${version} points to ${tagObject}, expected new tag object ${expectedTagObject}`,
);
}
const tagResponse = await readAfterWrite(
`${version} annotated tag object`,
() => github.rest.git.getTag({ owner, repo, tag_sha: tagObject }),
);
const attemptMatches = [
...tagResponse.data.message.matchAll(/^Release-Run-Attempt: ([1-9]\d*)$/gm),
];
if (attemptMatches.length !== 1) {
throw new Error(`${version} has invalid Release-Run-Attempt metadata`);
}
const sealedAttempt = Number(attemptMatches[0][1]);
if (
!Number.isSafeInteger(sealedAttempt) ||
sealedAttempt < 1 ||
sealedAttempt > currentAttempt ||
tagResponse.data.tag !== version ||
tagResponse.data.object.type !== "commit" ||
tagResponse.data.object.sha !== commit ||
tagResponse.data.message !== messageForAttempt(sealedAttempt)
) {
throw new Error(
`${version} existing annotated tag is not the exact seal from this workflow run`,
);
}
return tagObject;
};
const refs = [];
for (const prefix of ["tags/v", "tags/withdrawn/v"]) {
const matching = await github.paginate(github.rest.git.listMatchingRefs, {
owner,
repo,
ref: prefix,
per_page: 100,
});
for (const ref of matching) {
// A failed-jobs rerun keeps the successful release-plan output.
// Ignore only this run's candidate ref while recomputing that
// pre-write fingerprint; verify its complete authority below.
if (ref.ref === `refs/tags/${version}`) {
continue;
}
refs.push(`${ref.ref}=${ref.object.sha}`);
}
}
refs.sort();
const actualFingerprint = crypto
.createHash("sha256")
.update(refs.length > 0 ? `${refs.join("\n")}\n` : "")
.digest("hex");
if (actualFingerprint !== expectedFingerprint) {
core.setFailed("allocated release refs changed after planning; run the release again");
return;
}
let existingRef = null;
try {
existingRef = await github.rest.git.getRef({
owner,
repo,
ref: `tags/${version}`,
});
} catch (error) {
if (error.status !== 404) throw error;
}
try {
await github.rest.git.getRef({
owner,
repo,
ref: `tags/withdrawn/${version}`,
});
core.setFailed(`release version was withdrawn and cannot be reused: ${version}`);
return;
} catch (error) {
if (error.status !== 404) throw error;
}
if (branchMoved) {
if (!existingRef) {
core.setFailed(
`${defaultBranch} moved to ${currentBranchCommit} before the tag was created; run the release again`,
);
return;
}
const comparison = await github.rest.repos.compareCommitsWithBasehead({
owner,
repo,
basehead: `${commit}...${currentBranchCommit}`,
});
if (!["ahead", "identical"].includes(comparison.data.status)) {
core.setFailed(
`${version} sealed commit is no longer contained in ${defaultBranch}`,
);
return;
}
}
let sealedTagObject = "";
let reusedExistingSeal = false;
if (existingRef) {
sealedTagObject = await verifyExactTag(existingRef);
reusedExistingSeal = true;
core.info(
`Reusing exact ${version} seal from run ${context.runId}; continuing failed jobs without recovery approval`,
);
} else {
const message = messageForAttempt(currentAttempt);
const tag = await github.rest.git.createTag({
owner,
repo,
tag: version,
message,
object: commit,
type: "commit",
});
if (
tag.data.tag !== version ||
tag.data.object.type !== "commit" ||
tag.data.object.sha !== commit ||
tag.data.message !== message
) {
core.setFailed(`${version} annotated tag creation response differs from the seal`);
return;
}
let createRefResponse = null;
let createRefError = null;
for (const delayMs of postWriteRetryDelaysMs) {
if (delayMs > 0) {
core.info(
`${version} ref creation is not confirmed; retrying in ${delayMs}ms`,
);
await sleep(delayMs);
}
try {
createRefResponse = await github.rest.git.createRef({
owner,
repo,
ref: `refs/tags/${version}`,
sha: tag.data.sha,
});
break;
} catch (error) {
createRefError = error;
if (
error.status !== 422 &&
error.status !== 429 &&
!(Number.isInteger(error.status) && error.status >= 500 && error.status < 600)
) {
throw error;
}
try {
const observedRef = await github.rest.git.getRef({
owner,
repo,
ref: `tags/${version}`,
});
await verifyExactTag(observedRef, tag.data.sha);
createRefResponse = observedRef;
break;
} catch (observeError) {
if (!retryablePostWriteError(observeError)) {
throw observeError;
}
}
}
}
if (!createRefResponse) {
throw createRefError;
}
if (
createRefResponse.data.object.type !== "tag" ||
createRefResponse.data.object.sha !== tag.data.sha
) {
core.setFailed(`${version} ref creation response differs from the new tag object`);
return;
}
const sealedRef = await readAfterWrite(
`${version} tag ref`,
() => github.rest.git.getRef({ owner, repo, ref: `tags/${version}` }),
);
sealedTagObject = await verifyExactTag(sealedRef, tag.data.sha);
}
core.setOutput("release_version", version);
core.setOutput("release_commit", commit);
core.setOutput("release_tag_object", sealedTagObject);
await core.summary
.addHeading("Cloud release sealed")
.addRaw(
`\`${version}\` → \`${commit}\` (run ${context.runId}` +
`${reusedExistingSeal ? ", exact seal reused" : ""})`,
)
.write();