1028 lines
40 KiB
YAML
1028 lines
40 KiB
YAML
name: Release
|
|
|
|
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
|
|
|
|
permissions:
|
|
contents: read
|
|
|
|
# Queue tag releases 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:
|
|
release-contract:
|
|
if: ${{ github.event_name == 'push' }}
|
|
runs-on: ubuntu-latest
|
|
timeout-minutes: 20
|
|
permissions:
|
|
actions: read
|
|
checks: read
|
|
contents: read
|
|
outputs:
|
|
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: Check out repository
|
|
uses: actions/checkout@v4
|
|
with:
|
|
fetch-depth: 0
|
|
|
|
- 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
|
|
run: |
|
|
set -eu
|
|
. ./scripts/release/release-lib.sh
|
|
channel="$(release_channel_for_version "$GITHUB_REF_NAME")"
|
|
./scripts/release/release-contract.sh \
|
|
--channel "$channel" \
|
|
--version "$GITHUB_REF_NAME" \
|
|
--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 }}
|
|
run: ./scripts/release/verify-github-tag-authority.sh "$GITHUB_REF_NAME" "$GITHUB_SHA"
|
|
|
|
- name: Require successful CI Gate on the sealed commit
|
|
uses: actions/github-script@v7
|
|
with:
|
|
script: |
|
|
const { owner, repo } = context.repo;
|
|
const { data } = await github.rest.checks.listForRef({
|
|
owner,
|
|
repo,
|
|
ref: context.sha,
|
|
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 ${context.sha}`);
|
|
}
|
|
|
|
- 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: ./scripts/release/verify-delivered-stable.sh "$PREVIOUS_STABLE" "$PREVIOUS_STABLE_COMMIT"
|
|
|
|
- name: Require immutable releases governance
|
|
uses: actions/github-script@v7
|
|
with:
|
|
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;
|
|
}
|
|
const runs = await github.paginate(github.rest.actions.listWorkflowRuns, {
|
|
owner,
|
|
repo,
|
|
workflow_id: "release.yml",
|
|
branch: process.env.FROM_BETA,
|
|
event: "push",
|
|
status: "completed",
|
|
per_page: 100,
|
|
});
|
|
const passed = runs.some(
|
|
(run) =>
|
|
run.head_sha === process.env.FROM_BETA_COMMIT &&
|
|
run.head_branch === process.env.FROM_BETA &&
|
|
run.conclusion === "success",
|
|
);
|
|
if (!passed) {
|
|
core.setFailed(`Release workflow did not succeed for ${process.env.FROM_BETA}`);
|
|
}
|
|
|
|
- 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: ${{ github.event_name == 'push' }}
|
|
needs: [release-contract]
|
|
runs-on: ubuntu-latest
|
|
timeout-minutes: 60
|
|
permissions:
|
|
contents: read
|
|
steps:
|
|
- name: Check out repository
|
|
uses: actions/checkout@v4
|
|
with:
|
|
fetch-depth: 0
|
|
|
|
- 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: Revalidate contract and render release notes
|
|
run: |
|
|
./scripts/release/release-contract.sh \
|
|
--channel "${{ needs.release-contract.outputs.channel }}" \
|
|
--version "$GITHUB_REF_NAME" \
|
|
--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: 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: ${{ github.ref_name }}
|
|
|
|
- name: Post-release packaging
|
|
run: ./scripts/release/post-goreleaser.sh
|
|
env:
|
|
DWS_PACKAGE_VERSION: ${{ github.ref_name }}
|
|
DWS_RELEASE_BASE_URL: https://github.com/${{ github.repository }}/releases/download/${{ github.ref_name }}
|
|
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 "$GITHUB_REF_NAME"
|
|
|
|
- name: Verify npm package before sealing GitHub Release
|
|
run: ./scripts/release/verify-package-managers.sh --npm-only --expected-version "$GITHUB_REF_NAME"
|
|
|
|
- name: Reverify remote tag before sealing GitHub Release
|
|
env:
|
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
run: ./scripts/release/verify-github-tag-authority.sh "$GITHUB_REF_NAME" "$GITHUB_SHA"
|
|
|
|
- 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: ${{ github.event_name == 'push' }}
|
|
needs: 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: ${{ github.event_name == 'push' }}
|
|
needs: [release-contract, release, verify-darwin-signatures]
|
|
runs-on: ubuntu-latest
|
|
timeout-minutes: 30
|
|
permissions:
|
|
contents: write
|
|
steps:
|
|
- name: Check out sealed release source
|
|
uses: actions/checkout@v4
|
|
with:
|
|
fetch-depth: 0
|
|
|
|
- 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: Revalidate contract and render release notes
|
|
run: |
|
|
./scripts/release/release-contract.sh \
|
|
--channel "${{ needs.release-contract.outputs.channel }}" \
|
|
--version "$GITHUB_REF_NAME" \
|
|
--context ci \
|
|
--remote origin \
|
|
--branch main \
|
|
--notes-output "$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 "$GITHUB_REF_NAME"
|
|
|
|
- name: Verify npm package before sealing GitHub Release
|
|
run: ./scripts/release/verify-package-managers.sh --npm-only --expected-version "$GITHUB_REF_NAME"
|
|
|
|
- name: Reverify remote tag before sealing GitHub Release
|
|
env:
|
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
run: ./scripts/release/verify-github-tag-authority.sh "$GITHUB_REF_NAME" "$GITHUB_SHA"
|
|
|
|
- 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 "$GITHUB_REF_NAME" >/dev/null 2>&1; then
|
|
is_draft="$(gh release view "$GITHUB_REF_NAME" --json isDraft --jq .isDraft)"
|
|
is_prerelease="$(gh release view "$GITHUB_REF_NAME" --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/$GITHUB_REF_NAME" \
|
|
--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 "$GITHUB_REF_NAME" --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
|
|
published_dir="$RUNNER_TEMP/published-release"
|
|
mkdir -p "$published_dir"
|
|
gh release download "$GITHUB_REF_NAME" \
|
|
--dir "$published_dir" \
|
|
--pattern 'dws-*' \
|
|
--pattern 'checksums.txt' \
|
|
--clobber
|
|
DWS_PACKAGE_DIST_DIR="$published_dir" \
|
|
./scripts/release/verify-release-artifacts.sh "$GITHUB_REF_NAME"
|
|
for asset in "$published_dir"/dws-* "$published_dir"/checksums.txt; do
|
|
cp "$asset" dist/
|
|
done
|
|
DWS_PACKAGE_DIST_DIR="$GITHUB_WORKSPACE/dist" \
|
|
DWS_PACKAGE_SOURCE_ROOT="$GITHUB_WORKSPACE" \
|
|
./scripts/release/stage-npm-package.sh "$GITHUB_REF_NAME"
|
|
echo "Published GitHub Release is immutable and verified; reusing its exact assets."
|
|
exit 0
|
|
fi
|
|
gh release edit "$GITHUB_REF_NAME" \
|
|
--title "$GITHUB_REF_NAME" \
|
|
--notes-file "$RUNNER_TEMP/release-notes.md"
|
|
else
|
|
# shellcheck disable=SC2086
|
|
gh release create "$GITHUB_REF_NAME" \
|
|
--verify-tag \
|
|
--draft \
|
|
--title "$GITHUB_REF_NAME" \
|
|
--notes-file "$RUNNER_TEMP/release-notes.md" \
|
|
$prerelease_flag
|
|
fi
|
|
gh release upload "$GITHUB_REF_NAME" \
|
|
dist/dws-*.tar.gz \
|
|
dist/dws-windows-*.zip \
|
|
dist/dws-skills.zip \
|
|
dist/checksums.txt \
|
|
--clobber
|
|
./scripts/release/verify-github-release-assets.sh "$GITHUB_REF_NAME"
|
|
sealed_upload="$RUNNER_TEMP/sealed-upload"
|
|
rm -rf "$sealed_upload"
|
|
mkdir -p "$sealed_upload"
|
|
gh release download "$GITHUB_REF_NAME" \
|
|
--dir "$sealed_upload" \
|
|
--pattern 'dws-*' \
|
|
--pattern 'checksums.txt' \
|
|
--clobber
|
|
DWS_PACKAGE_DIST_DIR="$sealed_upload" \
|
|
./scripts/release/verify-release-artifacts.sh "$GITHUB_REF_NAME"
|
|
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 "$GITHUB_REF_NAME" --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/$GITHUB_REF_NAME" \
|
|
--jq .immutable
|
|
)"
|
|
test "$immutable" = true || {
|
|
echo "GitHub Release is not immutable; refusing downstream publication." >&2
|
|
exit 1
|
|
}
|
|
./scripts/release/verify-github-release-assets.sh "$GITHUB_REF_NAME"
|
|
./scripts/release/verify-github-tag-authority.sh "$GITHUB_REF_NAME" "$GITHUB_SHA"
|
|
|
|
- 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-${{ github.ref_name }}"
|
|
DWS_TAP_PR_TITLE: "chore: update Homebrew formula for ${{ github.ref_name }}"
|
|
DWS_TAP_COMMIT_MESSAGE: "chore: update formula for ${{ github.ref_name }}"
|
|
|
|
- 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-${{ github.ref_name }}"
|
|
DWS_TAP_PR_TITLE: "chore: update Homebrew beta formula for ${{ github.ref_name }}"
|
|
DWS_TAP_COMMIT_MESSAGE: "chore: update beta formula for ${{ github.ref_name }}"
|
|
|
|
- name: Reverify exact immutable npm package
|
|
run: ./scripts/release/verify-package-managers.sh --npm-only --expected-version "$GITHUB_REF_NAME"
|
|
|
|
publish-channels:
|
|
name: Publish npm and mirrors
|
|
if: ${{ github.event_name == 'push' }}
|
|
needs: [release-contract, publish-release]
|
|
runs-on: ubuntu-latest
|
|
timeout-minutes: 30
|
|
permissions:
|
|
contents: read
|
|
steps:
|
|
- name: Check out sealed release source
|
|
uses: actions/checkout@v4
|
|
with:
|
|
fetch-depth: 0
|
|
|
|
- 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: Revalidate sealed contract
|
|
run: |
|
|
./scripts/release/release-contract.sh \
|
|
--channel "${{ needs.release-contract.outputs.channel }}" \
|
|
--version "$GITHUB_REF_NAME" \
|
|
--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/$GITHUB_REF_NAME" \
|
|
--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 "$GITHUB_REF_NAME"
|
|
./scripts/release/verify-github-tag-authority.sh "$GITHUB_REF_NAME" "$GITHUB_SHA"
|
|
mkdir -p dist
|
|
gh release download "$GITHUB_REF_NAME" \
|
|
--dir dist \
|
|
--pattern 'dws-*' \
|
|
--pattern 'checksums.txt' \
|
|
--clobber
|
|
./scripts/release/verify-release-artifacts.sh "$GITHUB_REF_NAME"
|
|
DWS_PACKAGE_SOURCE_ROOT="$GITHUB_WORKSPACE" \
|
|
DWS_PACKAGE_DIST_DIR="$GITHUB_WORKSPACE/dist" \
|
|
./scripts/release/stage-npm-package.sh "$GITHUB_REF_NAME"
|
|
|
|
- name: Verify immutable npm package without publication credentials
|
|
run: |
|
|
./scripts/release/verify-package-managers.sh \
|
|
--npm-only \
|
|
--expected-version "$GITHUB_REF_NAME"
|
|
|
|
- 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="${GITHUB_REF_NAME#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" "$GITHUB_REF_NAME"; then
|
|
echo "npm $npm_tag already points to newer v$current; leaving it unchanged."
|
|
elif [ -z "$current" ] || release_version_is_greater "$GITHUB_REF_NAME" "v$current"; then
|
|
advance_channel=true
|
|
else
|
|
echo "Cannot reconcile npm $npm_tag=$current with $GITHUB_REF_NAME" >&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 "$GITHUB_REF_NAME" "v$current"; then
|
|
echo "$GITHUB_REF_NAME 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: ${{ github.ref_name }}
|
|
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, publish-channels]
|
|
runs-on: ubuntu-latest
|
|
timeout-minutes: 120
|
|
permissions:
|
|
contents: read
|
|
steps:
|
|
- name: Check out sealed release source
|
|
uses: actions/checkout@v4
|
|
timeout-minutes: 5
|
|
|
|
- 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: ${{ github.ref_name }}
|
|
GITEE_TOKEN: ${{ secrets.GITEE_TOKEN }}
|
|
GITEE_USER: ${{ secrets.GITEE_USER }}
|
|
GITEE_REPO: ${{ secrets.GITEE_REPO }}
|
|
DWS_REQUIRE_GITEE: "1"
|
|
|
|
repair-npm:
|
|
if: ${{ github.event_name == 'workflow_dispatch' && inputs.repair_npm_version != '' && 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.event.repository.default_branch }}
|
|
path: tooling
|
|
|
|
- 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.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
|
|
|
|
- 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 }}
|