Publish Claude Code Binaries / Stage and publish binaries (push) Canceled after 0s
Publish Tutti App Runtime / Build darwin-arm64 (push) Canceled after 0s
Publish Tutti App Runtime / Build darwin-amd64 (push) Canceled after 0s
Publish Tutti App Runtime / Build linux-amd64 (push) Canceled after 0s
Publish Tutti App Runtime / Build windows-amd64 (push) Canceled after 0s
Sync Tutti Agent Skills / sync (push) Canceled after 0s
Windows Agent Adapters / Test Windows agent adapters (push) Canceled after 0s
Windows Daemon Adapters / Test Windows daemon adapters (push) Canceled after 0s
Publish Tutti App Runtime / Publish runtime catalog (push) Canceled after 0s
790 lines
37 KiB
YAML
790 lines
37 KiB
YAML
name: Promote Desktop Release
|
|
|
|
on:
|
|
workflow_call:
|
|
inputs:
|
|
release_tag:
|
|
description: Existing GitHub draft release tag to promote
|
|
required: true
|
|
type: string
|
|
expected_target:
|
|
description: Optional expected commit SHA for caller-side consistency checks
|
|
required: false
|
|
type: string
|
|
default: ""
|
|
expected_channel:
|
|
description: Optional expected stable, rc, or beta channel
|
|
required: false
|
|
type: string
|
|
default: ""
|
|
source_ref:
|
|
description: Source ref recorded in public release metadata
|
|
required: false
|
|
type: string
|
|
default: ""
|
|
notify_feishu:
|
|
description: Send a Feishu card after promotion
|
|
required: false
|
|
type: boolean
|
|
default: true
|
|
outputs:
|
|
release_url:
|
|
description: Promoted GitHub release URL
|
|
value: ${{ jobs.promote.outputs.release_url }}
|
|
secrets:
|
|
FEISHU_RELEASE_WEBHOOK_URL:
|
|
required: false
|
|
workflow_dispatch:
|
|
inputs:
|
|
release_tag:
|
|
description: GitHub draft release tag to promote, such as v0.1.25
|
|
required: true
|
|
type: string
|
|
notify_feishu:
|
|
description: Send a Feishu card after promotion
|
|
required: false
|
|
type: boolean
|
|
default: true
|
|
|
|
permissions:
|
|
contents: write
|
|
id-token: write
|
|
|
|
concurrency:
|
|
group: desktop-release-promotion
|
|
cancel-in-progress: false
|
|
|
|
env:
|
|
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
|
|
|
|
jobs:
|
|
resolve:
|
|
name: Validate Draft Release
|
|
runs-on: ubuntu-latest
|
|
outputs:
|
|
release_channel: ${{ steps.release.outputs.release_channel }}
|
|
release_already_published: ${{ steps.release.outputs.release_already_published }}
|
|
approval_digest: ${{ steps.release.outputs.approval_digest }}
|
|
release_candidate_id: ${{ steps.release.outputs.release_candidate_id }}
|
|
release_draft_tag: ${{ steps.release.outputs.release_draft_tag }}
|
|
release_id: ${{ steps.release.outputs.release_id }}
|
|
release_make_latest: ${{ steps.release.outputs.release_make_latest }}
|
|
release_prerelease: ${{ steps.release.outputs.release_prerelease }}
|
|
release_tag: ${{ steps.release.outputs.release_tag }}
|
|
release_target: ${{ steps.release.outputs.release_target }}
|
|
release_version: ${{ steps.release.outputs.release_version }}
|
|
source_ref: ${{ steps.release.outputs.source_ref }}
|
|
|
|
steps:
|
|
- name: Checkout release tooling
|
|
uses: actions/checkout@v6
|
|
with:
|
|
fetch-depth: 0
|
|
|
|
- name: Validate release candidate
|
|
id: release
|
|
shell: bash
|
|
env:
|
|
EXPECTED_CHANNEL: ${{ inputs.expected_channel }}
|
|
EXPECTED_TARGET: ${{ inputs.expected_target }}
|
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
INPUT_RELEASE_TAG: ${{ inputs.release_tag }}
|
|
INPUT_SOURCE_REF: ${{ inputs.source_ref }}
|
|
run: |
|
|
release_tag="${INPUT_RELEASE_TAG}"
|
|
if [[ "${release_tag}" =~ ^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)-rc\.(0|[1-9][0-9]*)$ ]]; then
|
|
release_channel=rc
|
|
release_prerelease=true
|
|
release_make_latest=false
|
|
elif [[ "${release_tag}" =~ ^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)-beta\.(0|[1-9][0-9]*)$ ]]; then
|
|
release_channel=beta
|
|
release_prerelease=true
|
|
release_make_latest=false
|
|
elif [[ "${release_tag}" =~ ^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$ ]]; then
|
|
release_channel=stable
|
|
release_prerelease=false
|
|
release_make_latest=true
|
|
else
|
|
echo "release_tag must be vX.Y.Z, vX.Y.Z-rc.N, or vX.Y.Z-beta.N." >&2
|
|
exit 1
|
|
fi
|
|
|
|
release_version="${release_tag#v}"
|
|
release_candidate_tag="candidate-${release_tag}"
|
|
if [[ -n "${EXPECTED_CHANNEL}" && "${EXPECTED_CHANNEL}" != "${release_channel}" ]]; then
|
|
echo "Release channel mismatch: expected ${EXPECTED_CHANNEL}, got ${release_channel}." >&2
|
|
exit 1
|
|
fi
|
|
|
|
gh api "repos/${GITHUB_REPOSITORY}/releases?per_page=100" > releases.json
|
|
if [[ "${release_channel}" == "stable" ]]; then
|
|
jq "[.[] | select((.draft and (.tag_name == \"${release_tag}\" or .tag_name == \"${release_candidate_tag}\")) or ((.draft | not) and .tag_name == \"${release_tag}\" and (.prerelease | not)))] | first // empty" releases.json > release.json
|
|
else
|
|
jq "[.[] | select(.draft and .tag_name == \"${release_tag}\")] | first // empty" releases.json > release.json
|
|
fi
|
|
if ! jq -e . release.json >/dev/null 2>&1; then
|
|
echo "GitHub release ${release_tag} does not exist." >&2
|
|
exit 1
|
|
fi
|
|
actual_tag="$(jq -r .tag_name release.json)"
|
|
is_draft="$(jq -r .draft release.json)"
|
|
actual_prerelease="$(jq -r .prerelease release.json)"
|
|
asset_count="$(jq '.assets | length' release.json)"
|
|
if [[ "${actual_tag}" != "${release_tag}" && "${actual_tag}" != "${release_candidate_tag}" ]]; then
|
|
echo "GitHub release tag mismatch: expected ${release_candidate_tag}, got ${actual_tag}." >&2
|
|
exit 1
|
|
fi
|
|
if [[ "${actual_prerelease}" != "${release_prerelease}" ]]; then
|
|
echo "GitHub release prerelease flag does not match ${release_channel}." >&2
|
|
exit 1
|
|
fi
|
|
if (( asset_count == 0 )); then
|
|
echo "GitHub release ${release_tag} has no staged assets." >&2
|
|
exit 1
|
|
fi
|
|
release_already_published=false
|
|
if [[ "${is_draft}" != "true" ]]; then
|
|
if [[ "${release_channel}" == "stable" && "${actual_tag}" == "${release_tag}" ]]; then
|
|
release_already_published=true
|
|
else
|
|
echo "Release ${release_tag} must still be a GitHub draft." >&2
|
|
exit 1
|
|
fi
|
|
fi
|
|
if ! jq -e '.assets[] | select(.name == "SHA256SUMS.txt")' release.json >/dev/null; then
|
|
echo "GitHub release ${release_tag} is missing SHA256SUMS.txt." >&2
|
|
exit 1
|
|
fi
|
|
|
|
release_target="$(jq -r '.target_commitish // ""' release.json)"
|
|
source_ref="${INPUT_SOURCE_REF:-${release_target}}"
|
|
release_candidate_id=""
|
|
approval_digest=""
|
|
if [[ "${release_channel}" == "stable" ]]; then
|
|
draft_target_sha="$(git rev-parse "${release_target}^{commit}")"
|
|
RELEASE_TARGET="${draft_target_sha}" gh release download "${actual_tag}" \
|
|
--pattern release-candidate.json \
|
|
--pattern release-summary.json \
|
|
--pattern SHA256SUMS.txt \
|
|
--dir candidate-validation
|
|
jq -r .body release.json > candidate-release-body.md
|
|
node apps/desktop/scripts/extract-approved-release-summary.mjs \
|
|
candidate-release-body.md \
|
|
candidate-validation/release-summary.json \
|
|
approved-release-summary.json
|
|
verification="$(RELEASE_TAG="${release_tag}" RELEASE_TARGET="${draft_target_sha}" node apps/desktop/scripts/verify-release-candidate.mjs \
|
|
candidate-validation/release-candidate.json \
|
|
candidate-validation/SHA256SUMS.txt \
|
|
candidate-validation/release-summary.json \
|
|
approved-release-summary.json)"
|
|
release_target="$(node -e 'process.stdout.write(JSON.parse(process.argv[1]).targetCommit)' "${verification}")"
|
|
release_candidate_id="$(node -e 'process.stdout.write(JSON.parse(process.argv[1]).candidateId)' "${verification}")"
|
|
approval_digest="$(node -e 'process.stdout.write(JSON.parse(process.argv[1]).approvalDigest)' "${verification}")"
|
|
source_ref="$(jq -r .sourceRef candidate-validation/release-candidate.json)"
|
|
remote_tag_sha="$(git ls-remote --tags origin "refs/tags/${release_tag}" | awk '{print $1}')"
|
|
if [[ -n "${remote_tag_sha}" && "${remote_tag_sha}" != "${release_target}" ]]; then
|
|
echo "Stable tag ${release_tag} points to another commit; select a new version." >&2
|
|
exit 1
|
|
fi
|
|
else
|
|
git fetch --force origin "refs/tags/${release_tag}:refs/tags/${release_tag}"
|
|
release_target="$(git rev-list -n 1 "${release_tag}")"
|
|
fi
|
|
if [[ -n "${EXPECTED_TARGET}" ]]; then
|
|
expected_target_sha="$(git rev-parse "${EXPECTED_TARGET}^{commit}")"
|
|
if [[ "${release_target}" != "${expected_target_sha}" ]]; then
|
|
echo "Release target mismatch: expected ${expected_target_sha}, got ${release_target}." >&2
|
|
exit 1
|
|
fi
|
|
fi
|
|
|
|
echo "approval_digest=${approval_digest}" >> "$GITHUB_OUTPUT"
|
|
echo "release_already_published=${release_already_published}" >> "$GITHUB_OUTPUT"
|
|
echo "release_candidate_id=${release_candidate_id}" >> "$GITHUB_OUTPUT"
|
|
echo "release_draft_tag=${actual_tag}" >> "$GITHUB_OUTPUT"
|
|
echo "release_id=$(jq -r .id release.json)" >> "$GITHUB_OUTPUT"
|
|
echo "release_channel=${release_channel}" >> "$GITHUB_OUTPUT"
|
|
echo "release_make_latest=${release_make_latest}" >> "$GITHUB_OUTPUT"
|
|
echo "release_prerelease=${release_prerelease}" >> "$GITHUB_OUTPUT"
|
|
echo "release_tag=${release_tag}" >> "$GITHUB_OUTPUT"
|
|
echo "release_target=${release_target}" >> "$GITHUB_OUTPUT"
|
|
echo "release_version=${release_version}" >> "$GITHUB_OUTPUT"
|
|
echo "source_ref=${source_ref}" >> "$GITHUB_OUTPUT"
|
|
|
|
approve-stable:
|
|
name: Approve Stable Release
|
|
needs: resolve
|
|
if: ${{ needs.resolve.outputs.release_channel == 'stable' }}
|
|
runs-on: ubuntu-latest
|
|
environment: desktop-stable-release
|
|
steps:
|
|
- name: Record approval
|
|
run: echo "Stable candidate ${{ needs.resolve.outputs.release_candidate_id }} approved by ${{ github.actor }}."
|
|
|
|
promote:
|
|
name: Publish External Release
|
|
needs: [resolve, approve-stable]
|
|
if: ${{ always() && needs.resolve.result == 'success' && (needs.approve-stable.result == 'success' || needs.approve-stable.result == 'skipped') }}
|
|
runs-on: ubuntu-latest
|
|
outputs:
|
|
release_url: ${{ github.server_url }}/${{ github.repository }}/releases/tag/${{ needs.resolve.outputs.release_tag }}
|
|
env:
|
|
AWS_REGION: ${{ vars.AWS_REGION }}
|
|
TUTTI_ARTIFACTS_AWS_ROLE_ARN: ${{ vars.TUTTI_ARTIFACTS_AWS_ROLE_ARN }}
|
|
TUTTI_DESKTOP_RELEASE_ASSETS_BASE_URL: ${{ vars.TUTTI_DESKTOP_RELEASE_ASSETS_BASE_URL }}
|
|
TUTTI_DESKTOP_RELEASE_ASSETS_S3_BUCKET: ${{ vars.TUTTI_DESKTOP_RELEASE_ASSETS_S3_BUCKET }}
|
|
TUTTI_DESKTOP_RELEASE_ASSETS_S3_PREFIX: ${{ vars.TUTTI_DESKTOP_RELEASE_ASSETS_S3_PREFIX }}
|
|
TUTTI_DESKTOP_RELEASE_CHANNEL: ${{ needs.resolve.outputs.release_channel }}
|
|
TUTTI_DESKTOP_RELEASE_APPROVAL_DIGEST: ${{ needs.resolve.outputs.approval_digest }}
|
|
TUTTI_DESKTOP_RELEASE_ALREADY_PUBLISHED: ${{ needs.resolve.outputs.release_already_published }}
|
|
TUTTI_DESKTOP_RELEASE_CANDIDATE_ID: ${{ needs.resolve.outputs.release_candidate_id }}
|
|
TUTTI_DESKTOP_RELEASE_DRAFT_TAG: ${{ needs.resolve.outputs.release_draft_tag }}
|
|
TUTTI_DESKTOP_RELEASE_ID: ${{ needs.resolve.outputs.release_id }}
|
|
TUTTI_DESKTOP_RELEASE_MAKE_LATEST: ${{ needs.resolve.outputs.release_make_latest }}
|
|
TUTTI_DESKTOP_RELEASE_PRERELEASE: ${{ needs.resolve.outputs.release_prerelease }}
|
|
TUTTI_DESKTOP_RELEASE_TAG: ${{ needs.resolve.outputs.release_tag }}
|
|
TUTTI_DESKTOP_RELEASE_TARGET: ${{ needs.resolve.outputs.release_target }}
|
|
TUTTI_DESKTOP_RELEASE_VERSION: ${{ needs.resolve.outputs.release_version }}
|
|
|
|
steps:
|
|
- name: Checkout release target
|
|
uses: actions/checkout@v6
|
|
with:
|
|
ref: ${{ env.TUTTI_DESKTOP_RELEASE_TARGET }}
|
|
fetch-depth: 0
|
|
|
|
- name: Setup Node.js
|
|
uses: actions/setup-node@v6
|
|
with:
|
|
node-version-file: .node-version
|
|
|
|
- name: Verify managed app runtime is published
|
|
run: node tools/scripts/verify-tutti-app-runtime-release.mjs
|
|
|
|
- name: Download staged GitHub release assets
|
|
env:
|
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
run: gh release download "${TUTTI_DESKTOP_RELEASE_DRAFT_TAG}" --dir release-assets
|
|
|
|
- name: Verify staged release assets
|
|
run: |
|
|
shopt -s nullglob
|
|
test -f release-assets/SHA256SUMS.txt
|
|
test -f release-assets/release-summary.json
|
|
if ! grep -Eq " .+\.(dmg|zip|exe|AppImage)$" release-assets/SHA256SUMS.txt; then
|
|
echo "SHA256SUMS.txt does not contain an installable desktop artifact." >&2
|
|
exit 1
|
|
fi
|
|
windows_metadata="${TUTTI_DESKTOP_RELEASE_CHANNEL}.yml"
|
|
if [[ "${TUTTI_DESKTOP_RELEASE_CHANNEL}" == "stable" ]]; then
|
|
windows_metadata=latest.yml
|
|
fi
|
|
windows_installers=(release-assets/*-win-x64.exe)
|
|
windows_blockmaps=(release-assets/*-win-x64.exe.blockmap)
|
|
if (( ${#windows_installers[@]} != 1 )); then
|
|
echo "Expected exactly one Windows x64 installer, found ${#windows_installers[@]}." >&2
|
|
exit 1
|
|
fi
|
|
if (( ${#windows_blockmaps[@]} != 1 )); then
|
|
echo "Expected exactly one Windows x64 blockmap, found ${#windows_blockmaps[@]}." >&2
|
|
exit 1
|
|
fi
|
|
test -f "release-assets/${windows_metadata}"
|
|
(
|
|
cd release-assets
|
|
sha256sum --check SHA256SUMS.txt
|
|
)
|
|
|
|
- name: Validate Windows updater metadata
|
|
run: node apps/desktop/scripts/validate-windows-release-artifacts.mjs release-assets "${TUTTI_DESKTOP_RELEASE_CHANNEL}" "${TUTTI_DESKTOP_RELEASE_TAG}"
|
|
|
|
- name: Validate staged release summary
|
|
env:
|
|
RELEASE_CHANNEL: ${{ env.TUTTI_DESKTOP_RELEASE_CHANNEL }}
|
|
RELEASE_TAG: ${{ env.TUTTI_DESKTOP_RELEASE_TAG }}
|
|
RELEASE_TARGET: ${{ env.TUTTI_DESKTOP_RELEASE_TARGET }}
|
|
run: node apps/desktop/scripts/validate-release-summary.mjs release-assets/release-summary.json
|
|
|
|
- name: Freeze approved stable release summary
|
|
if: ${{ needs.resolve.outputs.release_channel == 'stable' }}
|
|
env:
|
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
run: |
|
|
gh api "repos/${GITHUB_REPOSITORY}/releases/${TUTTI_DESKTOP_RELEASE_ID}" \
|
|
--jq .body > approved-release-body.md
|
|
node apps/desktop/scripts/extract-approved-release-summary.mjs \
|
|
approved-release-body.md \
|
|
release-assets/release-summary.json \
|
|
approved-release-summary.json
|
|
verification="$(RELEASE_TAG="${TUTTI_DESKTOP_RELEASE_TAG}" RELEASE_TARGET="${TUTTI_DESKTOP_RELEASE_TARGET}" node apps/desktop/scripts/verify-release-candidate.mjs \
|
|
release-assets/release-candidate.json \
|
|
release-assets/SHA256SUMS.txt \
|
|
release-assets/release-summary.json \
|
|
approved-release-summary.json)"
|
|
actual_digest="$(node -e 'process.stdout.write(JSON.parse(process.argv[1]).approvalDigest)' "${verification}")"
|
|
if [[ "${actual_digest}" != "${TUTTI_DESKTOP_RELEASE_APPROVAL_DIGEST}" ]]; then
|
|
echo "Release notes or candidate assets changed after approval; submit approval again." >&2
|
|
exit 1
|
|
fi
|
|
cp release-assets/release-candidate.json promotion-candidate.json
|
|
mv approved-release-summary.json release-assets/release-summary.json
|
|
rm release-assets/release-candidate.json
|
|
node apps/desktop/scripts/generate-checksums.mjs release-assets
|
|
node apps/desktop/scripts/validate-release-summary.mjs release-assets/release-summary.json
|
|
|
|
- name: Install AWS CLI
|
|
timeout-minutes: 5
|
|
run: |
|
|
if ! command -v aws >/dev/null 2>&1; then
|
|
case "$(uname -m)" in
|
|
x86_64) awscli_arch="x86_64" ;;
|
|
aarch64|arm64) awscli_arch="aarch64" ;;
|
|
*)
|
|
echo "Unsupported architecture for AWS CLI installer: $(uname -m)" >&2
|
|
exit 1
|
|
;;
|
|
esac
|
|
awscli_dir="$(mktemp -d "${RUNNER_TEMP}/aws-cli.XXXXXX")"
|
|
curl --fail --silent --show-error --location \
|
|
--retry 3 \
|
|
--retry-all-errors \
|
|
--connect-timeout 10 \
|
|
--max-time 180 \
|
|
"https://awscli.amazonaws.com/awscli-exe-linux-${awscli_arch}.zip" \
|
|
--output "${awscli_dir}/awscliv2.zip"
|
|
python3 -m zipfile -e "${awscli_dir}/awscliv2.zip" "${awscli_dir}"
|
|
sudo "${awscli_dir}/aws/install" --update
|
|
fi
|
|
aws --version
|
|
|
|
- name: Validate AWS S3 release asset configuration
|
|
run: |
|
|
missing=()
|
|
for name in AWS_REGION TUTTI_ARTIFACTS_AWS_ROLE_ARN TUTTI_DESKTOP_RELEASE_ASSETS_S3_BUCKET TUTTI_DESKTOP_RELEASE_ASSETS_S3_PREFIX; do
|
|
if [[ -z "${!name:-}" ]]; then
|
|
missing+=("$name")
|
|
fi
|
|
done
|
|
if (( ${#missing[@]} > 0 )); then
|
|
echo "Missing required AWS S3 release asset configuration: ${missing[*]}" >&2
|
|
exit 1
|
|
fi
|
|
if [[ -z "${TUTTI_DESKTOP_RELEASE_ASSETS_BASE_URL:-}" ]]; then
|
|
echo "TUTTI_DESKTOP_RELEASE_ASSETS_BASE_URL=https://${TUTTI_DESKTOP_RELEASE_ASSETS_S3_BUCKET}.s3-accelerate.amazonaws.com/${TUTTI_DESKTOP_RELEASE_ASSETS_S3_PREFIX%/}" >> "$GITHUB_ENV"
|
|
fi
|
|
|
|
- name: Configure AWS credentials
|
|
uses: aws-actions/configure-aws-credentials@v4
|
|
with:
|
|
role-to-assume: ${{ env.TUTTI_ARTIFACTS_AWS_ROLE_ARN }}
|
|
aws-region: ${{ env.AWS_REGION }}
|
|
|
|
- name: Prevent release channel rollback
|
|
env:
|
|
RELEASE_CHANNEL: ${{ env.TUTTI_DESKTOP_RELEASE_CHANNEL }}
|
|
RELEASE_VERSION: ${{ env.TUTTI_DESKTOP_RELEASE_VERSION }}
|
|
run: |
|
|
case "${RELEASE_CHANNEL}" in
|
|
stable) pointer_path=latest.json ;;
|
|
rc) pointer_path=channels/rc/latest.json ;;
|
|
beta) pointer_path=channels/beta/latest.json ;;
|
|
esac
|
|
prefix="${TUTTI_DESKTOP_RELEASE_ASSETS_S3_PREFIX%/}"
|
|
current_pointer="s3://${TUTTI_DESKTOP_RELEASE_ASSETS_S3_BUCKET}/${prefix}/${pointer_path}"
|
|
if aws s3 cp "${current_pointer}" current-release-pointer.json 2> current-pointer-download.err; then
|
|
node <<'NODE'
|
|
const fs = require("node:fs");
|
|
const current = JSON.parse(fs.readFileSync("current-release-pointer.json", "utf8"));
|
|
const candidate = process.env.RELEASE_VERSION;
|
|
const parse = (value) => {
|
|
const match = /^(\d+)\.(\d+)\.(\d+)(?:-(rc|beta)\.(\d+))?$/.exec(value);
|
|
if (!match) throw new Error(`Invalid release version: ${value}`);
|
|
return [Number(match[1]), Number(match[2]), Number(match[3]), Number(match[5] ?? -1)];
|
|
};
|
|
const currentParts = parse(String(current.version ?? ""));
|
|
const candidateParts = parse(candidate);
|
|
const comparison = candidateParts.findIndex((part, index) => part !== currentParts[index]);
|
|
if (comparison >= 0 && candidateParts[comparison] < currentParts[comparison]) {
|
|
throw new Error(`Refusing to move ${process.env.RELEASE_CHANNEL} from ${current.version} back to ${candidate}.`);
|
|
}
|
|
if (comparison < 0 && current.tag !== process.env.TUTTI_DESKTOP_RELEASE_TAG) {
|
|
throw new Error(`Release version ${candidate} is already published by ${current.tag}.`);
|
|
}
|
|
NODE
|
|
elif ! grep -Eq "(404|NoSuchKey|Not Found)" current-pointer-download.err; then
|
|
cat current-pointer-download.err >&2
|
|
exit 1
|
|
fi
|
|
|
|
- name: Protect immutable stable asset path
|
|
if: ${{ needs.resolve.outputs.release_channel == 'stable' }}
|
|
run: |
|
|
prefix="${TUTTI_DESKTOP_RELEASE_ASSETS_S3_PREFIX%/}/${TUTTI_DESKTOP_RELEASE_TAG}"
|
|
marker="s3://${TUTTI_DESKTOP_RELEASE_ASSETS_S3_BUCKET}/${prefix}/.promotion-candidate.json"
|
|
checksum="s3://${TUTTI_DESKTOP_RELEASE_ASSETS_S3_BUCKET}/${prefix}/SHA256SUMS.txt"
|
|
if aws s3 cp "${marker}" existing-promotion-candidate.json 2>/dev/null; then
|
|
if ! cmp -s promotion-candidate.json existing-promotion-candidate.json; then
|
|
echo "Formal asset path belongs to another release candidate." >&2
|
|
exit 1
|
|
fi
|
|
else
|
|
aws s3 cp promotion-candidate.json "${marker}" \
|
|
--content-type "application/json" \
|
|
--cache-control "no-store"
|
|
fi
|
|
if aws s3 cp "${checksum}" existing-formal-checksums.txt 2>/dev/null; then
|
|
if ! cmp -s release-assets/SHA256SUMS.txt existing-formal-checksums.txt; then
|
|
echo "Published stable asset checksums are immutable and do not match this candidate." >&2
|
|
exit 1
|
|
fi
|
|
echo "TUTTI_DESKTOP_FORMAL_ASSETS_COMPLETE=true" >> "$GITHUB_ENV"
|
|
else
|
|
echo "TUTTI_DESKTOP_FORMAL_ASSETS_COMPLETE=false" >> "$GITHUB_ENV"
|
|
fi
|
|
|
|
- name: Create stable release tag after approval
|
|
if: ${{ needs.resolve.outputs.release_channel == 'stable' }}
|
|
env:
|
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
run: |
|
|
remote_tag_sha="$(git ls-remote --tags origin "refs/tags/${TUTTI_DESKTOP_RELEASE_TAG}" | awk '{print $1}')"
|
|
if [[ -z "${remote_tag_sha}" ]]; then
|
|
node apps/desktop/scripts/reserve-release-tag.mjs \
|
|
--strategy explicit_tag \
|
|
--tag "${TUTTI_DESKTOP_RELEASE_TAG}" \
|
|
--target "${TUTTI_DESKTOP_RELEASE_TARGET}" \
|
|
--json >/dev/null
|
|
elif [[ "${remote_tag_sha}" != "${TUTTI_DESKTOP_RELEASE_TARGET}" ]]; then
|
|
echo "Stable tag ${TUTTI_DESKTOP_RELEASE_TAG} points to another commit." >&2
|
|
exit 1
|
|
fi
|
|
|
|
- name: Attach approved draft to the stable tag
|
|
if: ${{ needs.resolve.outputs.release_channel == 'stable' && needs.resolve.outputs.release_already_published != 'true' }}
|
|
env:
|
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
run: |
|
|
gh api --method PATCH "repos/${GITHUB_REPOSITORY}/releases/${TUTTI_DESKTOP_RELEASE_ID}" \
|
|
-f tag_name="${TUTTI_DESKTOP_RELEASE_TAG}" \
|
|
-f target_commitish="${TUTTI_DESKTOP_RELEASE_TARGET}" \
|
|
-f name="${TUTTI_DESKTOP_RELEASE_TAG}" \
|
|
-F draft=true \
|
|
-F prerelease=false >/dev/null
|
|
if [[ "${TUTTI_DESKTOP_RELEASE_DRAFT_TAG}" != "${TUTTI_DESKTOP_RELEASE_TAG}" ]]; then
|
|
git push origin ":refs/tags/${TUTTI_DESKTOP_RELEASE_DRAFT_TAG}" || true
|
|
fi
|
|
|
|
- name: Upload immutable release assets to AWS S3
|
|
run: |
|
|
remote_dir="s3://${TUTTI_DESKTOP_RELEASE_ASSETS_S3_BUCKET}/${TUTTI_DESKTOP_RELEASE_ASSETS_S3_PREFIX%/}/${TUTTI_DESKTOP_RELEASE_TAG}"
|
|
if [[ "${TUTTI_DESKTOP_RELEASE_CHANNEL}" == "stable" ]]; then
|
|
if [[ "${TUTTI_DESKTOP_FORMAL_ASSETS_COMPLETE}" != "true" ]]; then
|
|
candidate_dir="s3://${TUTTI_DESKTOP_RELEASE_ASSETS_S3_BUCKET}/${TUTTI_DESKTOP_RELEASE_ASSETS_S3_PREFIX%/}/candidates/${TUTTI_DESKTOP_RELEASE_CANDIDATE_ID}"
|
|
aws s3 sync "${candidate_dir}" "${remote_dir}" \
|
|
--exclude "release-summary.json" \
|
|
--exclude "SHA256SUMS.txt" \
|
|
--exclude "release-candidate.json" \
|
|
--copy-props metadata-directive
|
|
aws s3 cp release-assets/release-summary.json "${remote_dir}/release-summary.json" \
|
|
--content-type "application/json" \
|
|
--cache-control "public, max-age=31536000, immutable"
|
|
aws s3 cp release-assets/SHA256SUMS.txt "${remote_dir}/SHA256SUMS.txt" \
|
|
--content-type "text/plain" \
|
|
--cache-control "public, max-age=31536000, immutable"
|
|
fi
|
|
else
|
|
aws s3 sync release-assets "${remote_dir}" \
|
|
--size-only \
|
|
--cache-control "public, max-age=31536000, immutable"
|
|
fi
|
|
|
|
- name: Verify mirrored Windows release assets
|
|
shell: bash
|
|
run: |
|
|
windows_metadata="${TUTTI_DESKTOP_RELEASE_CHANNEL}.yml"
|
|
if [[ "${TUTTI_DESKTOP_RELEASE_CHANNEL}" == "stable" ]]; then
|
|
windows_metadata=latest.yml
|
|
fi
|
|
windows_files=(
|
|
release-assets/*-win-x64.exe
|
|
release-assets/*-win-x64.exe.blockmap
|
|
"release-assets/${windows_metadata}"
|
|
)
|
|
prefix="${TUTTI_DESKTOP_RELEASE_ASSETS_S3_PREFIX%/}/${TUTTI_DESKTOP_RELEASE_TAG}"
|
|
for file in "${windows_files[@]}"; do
|
|
asset_name="$(basename "${file}")"
|
|
local_size="$(stat -c %s "${file}")"
|
|
remote_size="$(aws s3api head-object \
|
|
--bucket "${TUTTI_DESKTOP_RELEASE_ASSETS_S3_BUCKET}" \
|
|
--key "${prefix}/${asset_name}" \
|
|
--query ContentLength \
|
|
--output text)"
|
|
if [[ "${remote_size}" != "${local_size}" ]]; then
|
|
echo "Mirrored asset size mismatch for ${asset_name}: local ${local_size}, remote ${remote_size}." >&2
|
|
exit 1
|
|
fi
|
|
local_sha256="$(sha256sum "${file}" | cut -d ' ' -f 1)"
|
|
remote_url="${TUTTI_DESKTOP_RELEASE_ASSETS_BASE_URL%/}/${TUTTI_DESKTOP_RELEASE_TAG}/${asset_name}"
|
|
remote_sha256="$(curl --fail --silent --show-error --location \
|
|
--retry 5 --retry-all-errors "${remote_url}" | sha256sum | cut -d ' ' -f 1)"
|
|
if [[ "${remote_sha256}" != "${local_sha256}" ]]; then
|
|
echo "Mirrored asset checksum mismatch for ${asset_name}: local ${local_sha256}, remote ${remote_sha256}." >&2
|
|
exit 1
|
|
fi
|
|
done
|
|
|
|
- name: Upload desktop release summary artifact
|
|
uses: actions/upload-artifact@v7
|
|
with:
|
|
name: promoted-desktop-release-summary-${{ env.TUTTI_DESKTOP_RELEASE_TAG }}
|
|
path: release-assets/release-summary.json
|
|
|
|
- name: Update release notes with summary and direct downloads
|
|
env:
|
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
RELEASE_TAG: ${{ env.TUTTI_DESKTOP_RELEASE_TAG }}
|
|
run: |
|
|
export RELEASE_ASSET_BASE_URL="${TUTTI_DESKTOP_RELEASE_ASSETS_BASE_URL}"
|
|
export RELEASE_SUMMARY_MODE=published
|
|
gh release upload "${RELEASE_TAG}" \
|
|
release-assets/release-summary.json \
|
|
release-assets/SHA256SUMS.txt \
|
|
--clobber
|
|
gh release view "${RELEASE_TAG}" --json body --jq .body > release-body.md
|
|
node apps/desktop/scripts/upsert-release-summary.mjs \
|
|
release-body.md \
|
|
release-assets/release-summary.json \
|
|
release-body-with-summary.md
|
|
node apps/desktop/scripts/upsert-release-download-links.mjs \
|
|
release-body-with-summary.md \
|
|
release-assets \
|
|
updated-release-body.md
|
|
gh release edit "${RELEASE_TAG}" --notes-file updated-release-body.md
|
|
|
|
- name: Build public release pointer
|
|
env:
|
|
RELEASE_CHANNEL: ${{ env.TUTTI_DESKTOP_RELEASE_CHANNEL }}
|
|
RELEASE_GIT_SHA: ${{ env.TUTTI_DESKTOP_RELEASE_TARGET }}
|
|
RELEASE_SOURCE_REF: ${{ needs.resolve.outputs.source_ref }}
|
|
RELEASE_TAG: ${{ env.TUTTI_DESKTOP_RELEASE_TAG }}
|
|
run: |
|
|
export RELEASE_ASSET_BASE_URL="${TUTTI_DESKTOP_RELEASE_ASSETS_BASE_URL}"
|
|
node apps/desktop/scripts/build-release-latest.mjs release-assets release-latest.json
|
|
|
|
- name: Update stable changelog metadata
|
|
if: ${{ needs.resolve.outputs.release_channel == 'stable' }}
|
|
run: |
|
|
prefix="${TUTTI_DESKTOP_RELEASE_ASSETS_S3_PREFIX%/}"
|
|
s3_root="s3://${TUTTI_DESKTOP_RELEASE_ASSETS_S3_BUCKET}/${prefix}"
|
|
if ! aws s3 cp "${s3_root}/changelog.json" existing-changelog.json 2> changelog-download.err; then
|
|
if grep -Eq "(404|NoSuchKey|Not Found)" changelog-download.err; then
|
|
echo '{"schemaVersion":"tutti.desktop.changelog.v1","entries":[]}' > existing-changelog.json
|
|
else
|
|
cat changelog-download.err >&2
|
|
exit 1
|
|
fi
|
|
fi
|
|
node apps/desktop/scripts/upsert-release-changelog.mjs \
|
|
existing-changelog.json \
|
|
release-assets/release-summary.json \
|
|
changelog.json
|
|
aws s3 cp changelog.json "${s3_root}/changelog.json" \
|
|
--content-type "application/json" \
|
|
--cache-control "public, max-age=60"
|
|
|
|
- name: Publish stable GitHub release
|
|
if: ${{ needs.resolve.outputs.release_channel == 'stable' && needs.resolve.outputs.release_already_published != 'true' }}
|
|
env:
|
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
run: gh release edit "${TUTTI_DESKTOP_RELEASE_TAG}" --draft=false --latest
|
|
|
|
- name: Archive public GitHub prereleases
|
|
env:
|
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
run: |
|
|
gh api "repos/${GITHUB_REPOSITORY}/releases?per_page=100" --paginate \
|
|
--jq '.[] | select(.prerelease and (.draft | not)) | .id' |
|
|
while IFS= read -r release_id; do
|
|
if [[ -n "${release_id}" ]]; then
|
|
gh api --method PATCH "repos/${GITHUB_REPOSITORY}/releases/${release_id}" \
|
|
-F draft=true >/dev/null
|
|
fi
|
|
done
|
|
|
|
- name: Publish release pointer to AWS S3
|
|
run: |
|
|
prefix="${TUTTI_DESKTOP_RELEASE_ASSETS_S3_PREFIX%/}"
|
|
s3_root="s3://${TUTTI_DESKTOP_RELEASE_ASSETS_S3_BUCKET}/${prefix}"
|
|
if [[ "${TUTTI_DESKTOP_RELEASE_CHANNEL}" == "stable" ]]; then
|
|
aws s3 cp release-latest.json "${s3_root}/latest.json" \
|
|
--content-type "application/json" \
|
|
--cache-control "public, max-age=60"
|
|
elif [[ "${TUTTI_DESKTOP_RELEASE_CHANNEL}" == "rc" ]]; then
|
|
aws s3 cp release-latest.json "${s3_root}/channels/preview/latest.json" \
|
|
--content-type "application/json" \
|
|
--cache-control "public, max-age=60"
|
|
aws s3 cp release-latest.json "${s3_root}/channels/rc/latest.json" \
|
|
--content-type "application/json" \
|
|
--cache-control "public, max-age=60"
|
|
else
|
|
aws s3 cp release-latest.json "${s3_root}/channels/beta/latest.json" \
|
|
--content-type "application/json" \
|
|
--cache-control "public, max-age=60"
|
|
fi
|
|
|
|
- name: Refresh stable release alias
|
|
env:
|
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
run: |
|
|
if [[ "${TUTTI_DESKTOP_RELEASE_CHANNEL}" == "stable" ]]; then
|
|
stable_tag="${TUTTI_DESKTOP_RELEASE_TAG}"
|
|
else
|
|
stable_tag="$(gh release list \
|
|
--exclude-drafts \
|
|
--exclude-pre-releases \
|
|
--limit 200 \
|
|
--json tagName,isLatest,publishedAt \
|
|
--jq '[.[] | select(.tagName != "stable" and (.tagName | test("^v[0-9]+\\.[0-9]+\\.[0-9]+$")))] | (map(select(.isLatest)) | first) // first | .tagName // ""')"
|
|
fi
|
|
if [[ -z "${stable_tag}" ]]; then
|
|
echo "Could not resolve the current stable desktop release tag for the stable alias." >&2
|
|
exit 1
|
|
fi
|
|
|
|
git fetch --force origin "refs/tags/${stable_tag}:refs/tags/${stable_tag}"
|
|
stable_sha="$(git rev-list -n 1 "${stable_tag}")"
|
|
stable_tree="$(git rev-parse "${stable_sha}^{tree}")"
|
|
gh release view "${stable_tag}" --json body,name,tagName,url > stable-release.json
|
|
node apps/desktop/scripts/build-stable-release-alias-body.mjs \
|
|
stable-release.json \
|
|
stable-release-body.md
|
|
|
|
stable_alias_time="$(date -u +'%Y-%m-%dT%H:%M:%SZ')"
|
|
stable_alias_sha="$(
|
|
printf '%s\n' \
|
|
"chore(release): refresh stable alias for ${stable_tag}" \
|
|
"" \
|
|
"Canonical stable release: ${stable_tag}" \
|
|
"" \
|
|
"Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>" |
|
|
GIT_AUTHOR_NAME="github-actions[bot]" \
|
|
GIT_AUTHOR_EMAIL="41898282+github-actions[bot]@users.noreply.github.com" \
|
|
GIT_AUTHOR_DATE="${stable_alias_time}" \
|
|
GIT_COMMITTER_NAME="github-actions[bot]" \
|
|
GIT_COMMITTER_EMAIL="41898282+github-actions[bot]@users.noreply.github.com" \
|
|
GIT_COMMITTER_DATE="${stable_alias_time}" \
|
|
git commit-tree "${stable_tree}" -p "${stable_sha}"
|
|
)"
|
|
stable_alias_tree="$(git rev-parse "${stable_alias_sha}^{tree}")"
|
|
stable_alias_parent="$(git rev-parse "${stable_alias_sha}^")"
|
|
if [[ "${stable_alias_tree}" != "${stable_tree}" ]]; then
|
|
echo "Stable alias tree mismatch: expected ${stable_tree}, got ${stable_alias_tree}." >&2
|
|
exit 1
|
|
fi
|
|
if [[ "${stable_alias_parent}" != "${stable_sha}" ]]; then
|
|
echo "Stable alias parent mismatch: expected ${stable_sha}, got ${stable_alias_parent}." >&2
|
|
exit 1
|
|
fi
|
|
|
|
git tag -f stable "${stable_alias_sha}"
|
|
git push origin refs/tags/stable --force
|
|
if gh release view stable >/dev/null 2>&1; then
|
|
gh release delete stable --yes
|
|
fi
|
|
gh release create stable \
|
|
--verify-tag \
|
|
--title "Stable (Recommended)" \
|
|
--notes-file stable-release-body.md \
|
|
--latest=false
|
|
gh release edit "${stable_tag}" --latest
|
|
|
|
- name: Verify public release pointer
|
|
env:
|
|
RELEASE_CHANNEL: ${{ env.TUTTI_DESKTOP_RELEASE_CHANNEL }}
|
|
RELEASE_TAG: ${{ env.TUTTI_DESKTOP_RELEASE_TAG }}
|
|
run: |
|
|
case "${RELEASE_CHANNEL}" in
|
|
stable) pointer_path=latest.json ;;
|
|
rc) pointer_path=channels/rc/latest.json ;;
|
|
beta) pointer_path=channels/beta/latest.json ;;
|
|
esac
|
|
pointer_url="${TUTTI_DESKTOP_RELEASE_ASSETS_BASE_URL%/}/${pointer_path}"
|
|
for attempt in {1..13}; do
|
|
actual_tag="$(curl -fsSL "${pointer_url}" | jq -r '.tag // ""' || true)"
|
|
if [[ "${actual_tag}" == "${RELEASE_TAG}" ]]; then
|
|
exit 0
|
|
fi
|
|
if (( attempt < 13 )); then
|
|
sleep 5
|
|
fi
|
|
done
|
|
echo "Public release pointer did not resolve to ${RELEASE_TAG}: ${pointer_url}" >&2
|
|
exit 1
|
|
|
|
- name: Remove internal candidate manifest from published release
|
|
if: ${{ needs.resolve.outputs.release_channel == 'stable' }}
|
|
env:
|
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
run: gh release delete-asset "${TUTTI_DESKTOP_RELEASE_TAG}" release-candidate.json --yes
|
|
|
|
notify-feishu:
|
|
name: Notify Feishu
|
|
needs: [resolve, promote]
|
|
if: ${{ always() && needs.promote.result == 'success' && inputs.notify_feishu == true }}
|
|
runs-on: ubuntu-latest
|
|
env:
|
|
FEISHU_WEBHOOK_URL: ${{ secrets.FEISHU_RELEASE_WEBHOOK_URL }}
|
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
TUTTI_DESKTOP_RELEASE_ASSETS_BASE_URL: ${{ vars.TUTTI_DESKTOP_RELEASE_ASSETS_BASE_URL }}
|
|
TUTTI_DESKTOP_RELEASE_ASSETS_S3_BUCKET: ${{ vars.TUTTI_DESKTOP_RELEASE_ASSETS_S3_BUCKET }}
|
|
TUTTI_DESKTOP_RELEASE_ASSETS_S3_PREFIX: ${{ vars.TUTTI_DESKTOP_RELEASE_ASSETS_S3_PREFIX }}
|
|
RELEASE_ACTOR: ${{ github.actor }}
|
|
RELEASE_BRANCH: ${{ needs.resolve.outputs.source_ref }}
|
|
RELEASE_PUBLICATION_STATUS: published
|
|
RELEASE_TAG: ${{ needs.resolve.outputs.release_tag }}
|
|
RELEASE_TARGET: ${{ needs.resolve.outputs.release_target }}
|
|
RELEASE_URL: ${{ needs.promote.outputs.release_url }}
|
|
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
|
|
|
|
steps:
|
|
- name: Checkout notification script
|
|
if: env.FEISHU_WEBHOOK_URL != ''
|
|
uses: actions/checkout@v6
|
|
with:
|
|
ref: ${{ needs.resolve.outputs.release_target }}
|
|
|
|
- name: Setup Node.js
|
|
if: env.FEISHU_WEBHOOK_URL != ''
|
|
uses: actions/setup-node@v6
|
|
with:
|
|
node-version-file: .node-version
|
|
|
|
- name: Download release summary
|
|
if: env.FEISHU_WEBHOOK_URL != ''
|
|
uses: actions/download-artifact@v4
|
|
with:
|
|
name: promoted-desktop-release-summary-${{ needs.resolve.outputs.release_tag }}
|
|
path: release-summary
|
|
|
|
- name: Send release card
|
|
if: env.FEISHU_WEBHOOK_URL != ''
|
|
env:
|
|
RELEASE_SUMMARY_PATH: release-summary/release-summary.json
|
|
run: node apps/desktop/scripts/send-release-feishu-card.mjs
|
|
|
|
- name: Skip Feishu card
|
|
if: env.FEISHU_WEBHOOK_URL == ''
|
|
run: echo "FEISHU_RELEASE_WEBHOOK_URL is not configured; skipping Feishu notification."
|
|
|
|
submit-store:
|
|
name: Submit stable package to Microsoft Store
|
|
needs: [resolve, promote]
|
|
if: ${{ needs.resolve.outputs.release_channel == 'stable' && needs.promote.result == 'success' && vars.TUTTI_WINDOWS_STORE_SUBMISSION_ENABLED == 'true' }}
|
|
uses: ./.github/workflows/desktop-store-submit.yml
|
|
with:
|
|
release_tag: ${{ needs.resolve.outputs.release_tag }}
|
|
store_environment: microsoft-store-production
|
|
submit: true
|
|
target_commit: ${{ needs.resolve.outputs.release_target }}
|
|
secrets: inherit
|