Files
larksuite-cli/scripts/release-workflow.test.sh
2026-07-31 11:01:06 +08:00

195 lines
9.1 KiB
Bash
Executable File

#!/usr/bin/env bash
# Copyright (c) 2026 Lark Technologies Pte. Ltd.
# SPDX-License-Identifier: MIT
set -euo pipefail
# This verifies the release workflow's declarative contract. The shell commands
# inside individual steps are exercised by the beta release rehearsal instead.
ruby -ropen3 -ryaml <<'RUBY'
workflow = YAML.load_file(".github/workflows/release.yml")
goreleaser = YAML.load_file(".goreleaser.yml")
def contract_error(message)
abort("release workflow contract: #{message}")
end
def expect_equal(actual, expected, description)
return if actual == expected
contract_error("#{description}; expected #{expected.inspect}, got #{actual.inspect}")
end
def scalar_values(value)
case value
when Hash then value.values.flat_map { |item| scalar_values(item) }
when Array then value.flat_map { |item| scalar_values(item) }
else [value]
end
end
def action_references(value)
case value
when Hash
value.flat_map { |key, item| key == "uses" ? [item] : action_references(item) }
when Array
value.flat_map { |item| action_references(item) }
else
[]
end
end
jobs = workflow.fetch("jobs")
jobs.each do |job_name, job|
job.fetch("steps", []).each do |step|
run = step["run"]
next unless run.is_a?(String)
_stdout, stderr, status = Open3.capture3("bash", "-n", stdin_data: run)
contract_error("#{job_name}/#{step["name"]} has invalid bash syntax: #{stderr}") unless status.success?
end
end
expected_jobs = %w[preflight build-sign-notarize create-draft-release verify-macos publish-github publish-npm retry-guidance]
expect_equal(jobs.keys.sort, expected_jobs.sort, "release jobs")
expect_equal(workflow.fetch("concurrency"), {
"group" => "release-${{ github.ref_name }}",
"cancel-in-progress" => false,
}, "release concurrency")
expected_needs = {
"preflight" => nil,
"build-sign-notarize" => "preflight",
"create-draft-release" => %w[preflight build-sign-notarize],
"verify-macos" => %w[preflight build-sign-notarize create-draft-release],
"publish-github" => %w[preflight create-draft-release verify-macos],
"publish-npm" => %w[preflight build-sign-notarize publish-github],
"retry-guidance" => %w[preflight build-sign-notarize create-draft-release verify-macos publish-github publish-npm],
}
expected_needs.each do |job_name, needs|
expect_equal(jobs.fetch(job_name)["needs"], needs, "#{job_name} dependencies")
end
expected_permissions = {
"preflight" => { "contents" => "read" },
"build-sign-notarize" => { "contents" => "read" },
"create-draft-release" => { "contents" => "write" },
"verify-macos" => { "contents" => "read" },
"publish-github" => { "contents" => "write" },
"publish-npm" => { "contents" => "read", "id-token" => "write" },
"retry-guidance" => { "contents" => "read" },
}
expected_permissions.each do |job_name, permissions|
expect_equal(jobs.fetch(job_name)["permissions"], permissions, "#{job_name} permissions")
end
expected_timeouts = {
"build-sign-notarize" => 45,
"create-draft-release" => 15,
"verify-macos" => 20,
"publish-github" => 15,
"publish-npm" => 15,
}
expected_timeouts.each do |job_name, timeout|
expect_equal(jobs.fetch(job_name)["timeout-minutes"], timeout, "#{job_name} timeout")
end
expect_equal(jobs.fetch("build-sign-notarize").fetch("environment"), "npm-production", "signing approval environment")
contract_error("publish-npm must not request a second Environment approval") if jobs.fetch("publish-npm").key?("environment")
expect_equal(jobs.fetch("publish-npm").fetch("concurrency"), {
"group" => "npm-release-${{ needs.preflight.outputs.channel }}",
"queue" => "max",
"cancel-in-progress" => false,
}, "npm publication concurrency")
retry_guidance = jobs.fetch("retry-guidance")
retry_condition = "${{ always() && (needs.preflight.result == 'failure' || needs.build-sign-notarize.result == 'failure' || needs.create-draft-release.result == 'failure' || needs.verify-macos.result == 'failure' || needs.publish-github.result == 'failure' || needs.publish-npm.result == 'failure') }}"
expect_equal(retry_guidance.fetch("if"), retry_condition, "retry guidance failure condition")
expect_equal(retry_guidance.fetch("runs-on"), "ubuntu-22.04", "retry guidance runner")
retry_steps = retry_guidance.fetch("steps")
expect_equal(retry_steps.length, 1, "number of retry guidance steps")
retry_step = retry_steps.first
expect_equal(retry_step.fetch("name"), "Write retry guidance", "retry guidance step name")
contract_error("retry guidance must write to the GitHub step summary") unless retry_step.fetch("run").include?("GITHUB_STEP_SUMMARY")
signing_references = %w[
secrets.MACOS_SIGN_P12
secrets.MACOS_SIGN_PASSWORD
secrets.MACOS_NOTARY_KEY
vars.MACOS_NOTARY_KEY_ID
vars.MACOS_NOTARY_ISSUER_ID
]
team_reference = "vars.MACOS_TEAM_ID"
jobs.each do |job_name, job|
references = scalar_values(job).grep(String).flat_map do |value|
(signing_references + [team_reference]).select { |reference| value.include?(reference) }
end.uniq.sort
expected_references = case job_name
when "build-sign-notarize" then signing_references + [team_reference]
when "verify-macos" then [team_reference]
else []
end
expect_equal(
references,
expected_references.sort,
"#{job_name} Apple credential scope",
)
end
macos = jobs.fetch("verify-macos")
expect_equal(macos.fetch("strategy").fetch("matrix").fetch("include"), [
{ "runner" => "macos-15-intel", "arch" => "amd64" },
{ "runner" => "macos-15", "arch" => "arm64" },
], "macOS verification matrix")
expect_equal(macos.fetch("runs-on"), "${{ matrix.runner }}", "macOS matrix runner")
macos_verify_step = macos.fetch("steps").find { |step| step["name"] == "Verify notarized macOS binary" }
macos_verify_run = macos_verify_step&.fetch("run", nil)
macos_download_step = macos.fetch("steps").find { |step| step["name"] == "Download release candidate" }
contract_error("verify-macos must download the build candidate artifact") unless macos_download_step&.fetch("uses", nil)&.start_with?("actions/download-artifact@")
contract_error("verify-macos must not download mutable Draft Release assets") if macos_verify_run&.include?("gh release download")
contract_error("verify-macos must verify notarization through codesign") unless macos_verify_run&.include?("--check-notarization -R='notarized'")
contract_error("verify-macos must detect hardened runtime in CodeDirectory metadata") unless macos_verify_run&.include?("^CodeDirectory .*flags=0x")
contract_error("verify-macos must match the complete release version") unless macos_verify_run&.include?("escaped_version")
draft_step = jobs.fetch("create-draft-release").fetch("steps").find { |step| step["name"] == "Create or reuse Draft Release" }
draft_run = draft_step&.fetch("run", nil)
contract_error("Draft Release creation must write generated release notes") unless draft_run&.include?("--notes-file")
contract_error("Draft Release reuse must validate target commit and prerelease state") unless draft_run&.include?("targetCommitish") && draft_run.include?("isPrerelease")
contract_error("Draft Release creation must target the validated source commit") unless draft_run&.include?("--target \"$SOURCE_SHA\"")
contract_error("Draft Release creation must require the existing remote tag") unless draft_run&.include?("--verify-tag")
github_steps = jobs.fetch("publish-github").fetch("steps")
github_check = github_steps.find { |step| step["name"] == "Verify Draft assets match the candidate" }
contract_error("GitHub publication must verify Draft assets against the candidate") unless github_check&.fetch("run", nil)&.include?("release-candidate/checksums.txt")
npm_steps = jobs.fetch("publish-npm").fetch("steps")
pinned_npm = npm_steps.find { |step| step["name"] == "Install pinned npm" }
contract_error("publish-npm must install npm 11.16.0 for trusted publishing") unless pinned_npm&.fetch("run", nil) == "npm install --global npm@11.16.0"
publish_step = npm_steps.find { |step| step["name"] == "Publish or verify npm package" }
contract_error("publish-npm must explicitly pass the candidate tarball as a local path") unless publish_step&.fetch("run", nil).include?('npm publish "./$tgz"')
action_references(workflow).each do |reference|
contract_error("action is not pinned to a full commit SHA: #{reference}") unless reference.match?(%r{\A[^@]+@[0-9a-f]{40}\z})
end
notarize = goreleaser.fetch("notarize").fetch("macos")
expect_equal(notarize.length, 1, "number of macOS notarization configurations")
macos_notarize = notarize.first
expect_equal(macos_notarize.fetch("enabled"), '{{ isEnvSet "MACOS_SIGN_P12" }}', "macOS notarization enablement")
expect_equal(macos_notarize.fetch("ids"), ["lark-cli"], "notarized build IDs")
expect_equal(macos_notarize.fetch("sign"), {
"certificate" => "{{ .Env.MACOS_SIGN_P12 }}",
"password" => "{{ .Env.MACOS_SIGN_PASSWORD }}",
}, "macOS signing inputs")
expect_equal(macos_notarize.fetch("notarize"), {
"issuer_id" => "{{ .Env.MACOS_NOTARY_ISSUER_ID }}",
"key_id" => "{{ .Env.MACOS_NOTARY_KEY_ID }}",
"key" => "{{ .Env.MACOS_NOTARY_KEY_PATH }}",
"wait" => true,
"timeout" => "20m",
}, "macOS notarization inputs")
puts "release workflow contract passed"
RUBY