Files
dingtalk-workspace-cli/.github/workflows/release.yml
T

1594 lines
66 KiB
YAML

name: Release
run-name: ${{ 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.workflow }}
on:
push:
tags:
- "v*"
workflow_dispatch:
inputs:
repair_npm_version:
description: "Recover npm for a public immutable release without moving latest/beta"
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_release_nonce:
description: "Unique identifier for this protected 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 npm 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 }}
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_NONCE: ${{ inputs.recover_release_nonce }}
RECOVER_CONFIRMATION: ${{ inputs.recover_release_confirmation }}
run: |
set -eu
repair=0
governance=0
recovery=0
test -z "$REPAIR_NPM_VERSION" || 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_NONCE" || \
test -n "$RECOVER_CONFIRMATION"; then recovery=1; fi
test $((repair + governance + recovery)) -eq 1 || {
echo "workflow_dispatch must select exactly one release mode" >&2
exit 1
}
if test "$repair" -eq 1; then
echo "mode=repair_npm" >> "$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_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_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
authorize-recovery:
name: Approve existing tag recovery
needs: dispatch-contract
if: ${{ needs.dispatch-contract.outputs.mode == 'recover_release' }}
environment: release-recovery
runs-on: ubuntu-latest
timeout-minutes: 2
permissions:
actions: read
steps:
- name: Verify break-glass environment protection
uses: actions/github-script@v7
with:
script: |
const { owner, repo } = context.repo;
const response = await github.request(
"GET /repos/{owner}/{repo}/environments/{environment_name}",
{ owner, repo, environment_name: "release-recovery" },
);
const reviewerRule = response.data.protection_rules.find(
(rule) => rule.type === "required_reviewers",
);
if (
!reviewerRule ||
reviewerRule.prevent_self_review !== true ||
!Array.isArray(reviewerRule.reviewers) ||
reviewerRule.reviewers.length === 0
) {
core.setFailed("release-recovery must require a reviewer and prevent self-review");
return;
}
if (response.data.deployment_branch_policy?.protected_branches !== true) {
core.setFailed("release-recovery must allow only protected branches");
}
if (response.data.can_admins_bypass !== false) {
core.setFailed("release-recovery must not allow administrator bypass");
}
governance-preflight:
name: Release governance preflight
needs: dispatch-contract
if: ${{ needs.dispatch-contract.outputs.mode == 'governance_preflight' }}
runs-on: ubuntu-latest
timeout-minutes: 5
permissions:
checks: read
steps:
- name: Validate trusted governance preflight context
env:
DEFAULT_BRANCH: ${{ github.event.repository.default_branch }}
EXPECTED_REPOSITORY: DingTalk-Real-AI/dingtalk-workspace-cli
PREFLIGHT_COMMIT: ${{ 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
}
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
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 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 successful CI Gate on the preflight commit
uses: actions/github-script@v7
env:
PREFLIGHT_COMMIT: ${{ inputs.governance_preflight_commit }}
with:
script: |
const { owner, repo } = context.repo;
const { data } = await github.rest.checks.listForRef({
owner,
repo,
ref: process.env.PREFLIGHT_COMMIT,
check_name: "CI Gate",
filter: "latest",
per_page: 100,
});
const passed = data.check_runs.some(
(run) => run.name === "CI Gate" && run.conclusion === "success",
);
if (!passed) {
core.setFailed(`CI Gate has not succeeded for ${process.env.PREFLIGHT_COMMIT}`);
}
- 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, authorize-recovery]
if: ${{ always() && (github.event_name == 'push' || (needs.dispatch-contract.result == 'success' && needs.dispatch-contract.outputs.mode == 'recover_release' && needs.authorize-recovery.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 }}
is_recovery: ${{ steps.target.outputs.is_recovery }}
failed_run_id: ${{ steps.target.outputs.failed_run_id }}
channel: ${{ steps.contract.outputs.channel }}
previous_stable: ${{ steps.contract.outputs.previous_stable }}
previous_stable_commit: ${{ steps.contract.outputs.previous_stable_commit }}
from_beta: ${{ steps.contract.outputs.from_beta }}
from_beta_commit: ${{ steps.contract.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 }}
with:
script: |
const { owner, repo } = context.repo;
const isRecovery = context.eventName === "workflow_dispatch";
let version = context.ref.replace("refs/tags/", "");
let commit = context.sha;
let expectedTagObject = "";
let failedRunId = "";
if (isRecovery) {
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 = process.env.RECOVER_VERSION;
commit = process.env.RECOVER_COMMIT;
expectedTagObject = process.env.RECOVER_TAG_OBJECT;
failedRunId = process.env.RECOVER_FAILED_RUN_ID;
if (!/^v(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)(?:-beta\.[1-9]\d*)?$/.test(version)) {
core.setFailed(`invalid recovery release version: ${version}`);
return;
}
if (!/^[0-9a-f]{40}$/.test(commit) || !/^[0-9a-f]{40}$/.test(expectedTagObject)) {
core.setFailed("recovery tag object and commit must be full lowercase SHA-1 values");
return;
}
if (!/^[1-9]\d*$/.test(failedRunId)) {
core.setFailed("recover_failed_run_id must be a positive workflow run ID");
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 && tagObject !== expectedTagObject) {
core.setFailed(`${version} tag object is ${tagObject}, expected ${expectedTagObject}`);
return;
}
if (isRecovery) {
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;
}
const failedRun = await github.rest.actions.getWorkflowRun({
owner,
repo,
run_id: Number(failedRunId),
});
const run = failedRun.data;
if (
run.repository.full_name !== `${owner}/${repo}` ||
run.path !== ".github/workflows/release.yml" ||
run.event !== "push" ||
run.status !== "completed" ||
!["failure", "cancelled", "timed_out", "startup_failure", "stale"].includes(run.conclusion) ||
run.head_branch !== version ||
run.head_sha !== commit
) {
core.setFailed(`run ${failedRunId} is not the failed exact-tag Release run 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("is_recovery", isRecovery ? "true" : "false");
core.setOutput("failed_run_id", failedRunId);
- name: Check out repository
uses: actions/checkout@v4
with:
ref: ${{ steps.target.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*'
- name: Check Homebrew PR automation token
if: ${{ github.repository_owner == 'DingTalk-Real-AI' }}
env:
HOMEBREW_PR_TOKEN: ${{ secrets.HOMEBREW_PR_TOKEN }}
run: |
if [ -z "${HOMEBREW_PR_TOKEN:-}" ]; then
echo "HOMEBREW_PR_TOKEN is required to open Formula PRs from official releases" >&2
exit 1
fi
- name: Validate release contract
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
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 CI Gate 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 { data } = await github.rest.checks.listForRef({
owner,
repo,
ref: process.env.RELEASE_COMMIT,
check_name: "CI Gate",
filter: "latest",
per_page: 100,
});
const passed = data.check_runs.some(
(run) => run.name === "CI Gate" && run.conclusion === "success",
);
if (!passed) {
core.setFailed(`CI Gate has not succeeded for ${process.env.RELEASE_COMMIT}`);
}
- name: Require delivered previous stable baseline
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' }}
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' }}
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: Set up Go
uses: actions/setup-go@v5
with:
go-version-file: go.mod
- name: Test release automation
run: go test -v -count=1 -timeout=5m ./test/scripts/... -run '^TestRelease'
- name: Verify module files are tidy
run: |
go mod tidy
git diff --exit-code -- go.mod go.sum
- name: Verify released command tree
env:
PREVIOUS_STABLE: ${{ steps.contract.outputs.previous_stable }}
run: |
set -eu
test -n "$PREVIOUS_STABLE"
./scripts/policy/check-command-compatibility.sh \
--base-ref HEAD \
--stable-ref "$PREVIOUS_STABLE"
release:
name: Build signed release artifacts
if: ${{ 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*'
- 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: 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: Multi Profile E2E
run: bash scripts/dev/test-multi-profile-e2e.sh
- 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: 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: ${{ 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: ${{ needs.release-contract.result == 'success' && needs.release.result == 'success' && needs.verify-darwin-signatures.result == 'success' }}
needs: [release-contract, release, verify-darwin-signatures]
runs-on: ubuntu-latest
timeout-minutes: 30
permissions:
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*'
- 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
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
if gh release view "$RELEASE_VERSION" >/dev/null 2>&1; then
is_draft="$(gh release view "$RELEASE_VERSION" --json isDraft --jq .isDraft)"
is_prerelease="$(gh release view "$RELEASE_VERSION" --json isPrerelease --jq .isPrerelease)"
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/tags/$RELEASE_VERSION" \
--jq .immutable
)"
if [ "$is_immutable" != "true" ]; then
echo "Published release is mutable; refusing to reuse its assets." >&2
exit 1
fi
remote_body="$(gh release view "$RELEASE_VERSION" --json body --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"
gh release download "$RELEASE_VERSION" \
--dir "$published_dir" \
--pattern 'dws-*' \
--pattern 'checksums.txt' \
--clobber
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 "Published GitHub Release is immutable and verified; reusing its exact assets."
exit 0
fi
gh release edit "$RELEASE_VERSION" \
--title "$RELEASE_VERSION" \
--notes-file "$RUNNER_TEMP/release-notes.md"
else
# shellcheck disable=SC2086
gh release create "$RELEASE_VERSION" \
--verify-tag \
--draft \
--title "$RELEASE_VERSION" \
--notes-file "$RUNNER_TEMP/release-notes.md" \
$prerelease_flag
fi
gh release upload "$RELEASE_VERSION" \
dist/dws-*.tar.gz \
dist/dws-windows-*.zip \
dist/dws-skills.zip \
dist/checksums.txt \
--clobber
./scripts/release/verify-github-release-assets.sh "$RELEASE_VERSION"
sealed_upload="$RUNNER_TEMP/sealed-upload"
rm -rf "$sealed_upload"
mkdir -p "$sealed_upload"
gh release download "$RELEASE_VERSION" \
--dir "$sealed_upload" \
--pattern 'dws-*' \
--pattern 'checksums.txt' \
--clobber
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 release edit "$RELEASE_VERSION" --draft=false
- name: Require immutable published GitHub Release
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
set -eu
immutable="$(
gh api -H 'X-GitHub-Api-Version: 2026-03-10' \
"repos/$GITHUB_REPOSITORY/releases/tags/$RELEASE_VERSION" \
--jq .immutable
)"
test "$immutable" = true || {
echo "GitHub Release is not immutable; refusing downstream 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"
- name: Open stable Homebrew formula PR
if: ${{ github.repository_owner == 'DingTalk-Real-AI' && needs.release-contract.outputs.channel == 'stable' }}
run: ./scripts/release/publish-homebrew-formula.sh
env:
DWS_TAP_REPO_URL: https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli.git
DWS_TAP_GITHUB_TOKEN: ${{ secrets.HOMEBREW_PR_TOKEN }}
DWS_TAP_PR_REPOSITORY: ${{ github.repository }}
DWS_TAP_PR_BRANCH: "automation/homebrew-${{ needs.release-contract.outputs.release_version }}"
DWS_TAP_PR_TITLE: "chore: update Homebrew formula for ${{ needs.release-contract.outputs.release_version }}"
DWS_TAP_COMMIT_MESSAGE: "chore: update formula for ${{ needs.release-contract.outputs.release_version }}"
- name: Open beta Homebrew formula PR
if: ${{ github.repository_owner == 'DingTalk-Real-AI' && needs.release-contract.outputs.channel == 'prerelease' }}
run: ./scripts/release/publish-homebrew-formula.sh
env:
DWS_FORMULA_SOURCE: 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_GITHUB_TOKEN: ${{ secrets.HOMEBREW_PR_TOKEN }}
DWS_TAP_PR_REPOSITORY: ${{ github.repository }}
DWS_TAP_PR_BRANCH: "automation/homebrew-beta-${{ needs.release-contract.outputs.release_version }}"
DWS_TAP_PR_TITLE: "chore: update Homebrew beta formula for ${{ needs.release-contract.outputs.release_version }}"
DWS_TAP_COMMIT_MESSAGE: "chore: update beta formula for ${{ needs.release-contract.outputs.release_version }}"
- 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: ${{ 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*'
- 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
delivered="$(npm view dingtalk-workspace-cli "dist-tags.$NPM_TAG")"
if [ "$delivered" = "$SEMVER" ]; then exit 0; fi
release_version_is_greater "v$delivered" "v$SEMVER" || {
echo "npm $NPM_TAG=$delivered does not deliver or supersede v$SEMVER" >&2
exit 1
}
echo "npm $NPM_TAG already supersedes this historical rerun at v$delivered."
- name: Sync release artifacts to China OSS mirror
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: ${{ github.repository_owner == 'DingTalk-Real-AI' && '1' || '0' }}
mirror-gitee-release:
name: Mirror immutable release to Gitee
if: ${{ 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: 120
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
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 }}
run: |
git fetch --force --no-tags \
https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli.git \
'+refs/tags/v*:refs/tags/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: 100
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;
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;
}
const runs = await github.paginate(github.rest.actions.listWorkflowRuns, {
owner,
repo,
workflow_id: "release.yml",
branch: version,
event: "push",
status: "completed",
per_page: 100,
});
let passed = false;
for (const run of runs) {
if (run.head_sha !== commitSha || run.head_branch !== version) continue;
const jobs = await github.paginate(github.rest.actions.listJobsForWorkflowRun, {
owner,
repo,
run_id: run.id,
filter: "all",
per_page: 100,
});
if (
jobs.some(
(job) =>
job.name === "Publish immutable GitHub Release" &&
job.head_sha === commitSha &&
job.steps?.some(
(step) =>
step.name === "Require immutable published GitHub Release" &&
step.conclusion === "success",
),
)
) {
passed = true;
break;
}
}
if (!passed) {
const recoveryTitle = `Release recovery ${version} at ${commitSha}`;
const recoveries = await github.paginate(github.rest.actions.listWorkflowRuns, {
owner,
repo,
workflow_id: "release.yml",
branch: defaultBranch,
event: "workflow_dispatch",
status: "completed",
per_page: 100,
});
for (const run of recoveries) {
const nonce = run.display_title.startsWith(`${recoveryTitle} `)
? run.display_title.slice(recoveryTitle.length + 1)
: "";
if (
!new RegExp(`^${commitSha}-[0-9]+-[0-9]+$`).test(nonce) ||
run.head_branch !== defaultBranch ||
run.path !== ".github/workflows/release.yml" ||
run.repository.full_name !== `${owner}/${repo}`
) {
continue;
}
const workflowComparison = await github.rest.repos.compareCommitsWithBasehead({
owner,
repo,
basehead: `${run.head_sha}...${branch.data.commit.sha}`,
});
if (!["ahead", "identical"].includes(workflowComparison.data.status)) continue;
const jobs = await github.paginate(github.rest.actions.listJobsForWorkflowRun, {
owner,
repo,
run_id: run.id,
filter: "all",
per_page: 100,
});
if (
jobs.some(
(job) =>
job.name === "Publish immutable GitHub Release" &&
job.head_sha === run.head_sha &&
job.steps?.some(
(step) =>
step.name === "Require immutable published GitHub Release" &&
step.conclusion === "success",
),
)
) {
passed = true;
break;
}
}
}
if (!passed) {
core.setFailed(`Immutable GitHub Release job did not succeed for ${version} at ${commitSha}`);
return;
}
core.setOutput("commit_sha", commitSha);
- 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 }}