Files
iOS/.github/workflows/release_macos.yml

437 lines
15 KiB
YAML

name: Release macOS
on:
workflow_dispatch:
inputs:
release_tag:
description: Release tag to publish, for example release/2026.5.0/2026.1985
required: true
type: string
distribute_run_id:
description: Optional Distribute workflow run ID to use directly
required: false
type: string
permissions:
actions: read
contents: write
concurrency:
group: macos-release-${{ inputs.release_tag || github.ref_name }}
cancel-in-progress: false
jobs:
release:
runs-on: ubuntu-latest
timeout-minutes: 45
steps:
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
fetch-depth: 0
- name: Parse release tag
id: parse_tag
env:
RELEASE_TAG: ${{ inputs.release_tag || '' }}
run: |
tag="${RELEASE_TAG:-${GITHUB_REF_NAME}}"
case "$tag" in
release/*/*) ;;
*)
echo "Unexpected tag format: $tag"
exit 1
;;
esac
git fetch --force origin "refs/tags/${tag}:refs/tags/${tag}"
version="${tag#release/}"
version="${version%%/*}"
build="${tag##*/}"
display_build="${build##*.}"
run_number="${display_build}"
commit_sha="$(git rev-parse "${tag}^{commit}")"
echo "tag=$tag" >> "$GITHUB_OUTPUT"
echo "version=$version" >> "$GITHUB_OUTPUT"
echo "build=$build" >> "$GITHUB_OUTPUT"
echo "display_build=$display_build" >> "$GITHUB_OUTPUT"
echo "run_number=$run_number" >> "$GITHUB_OUTPUT"
echo "commit_sha=$commit_sha" >> "$GITHUB_OUTPUT"
- name: Ensure tag points to main
env:
TAG_NAME: ${{ steps.parse_tag.outputs.tag }}
TARGET_COMMIT_SHA: ${{ steps.parse_tag.outputs.commit_sha }}
run: |
git fetch origin main
if ! git branch -r --contains "$TARGET_COMMIT_SHA" | grep -Eq 'origin/main$'; then
echo "Tag ${TAG_NAME} does not point to a commit on origin/main"
exit 1
fi
- name: Wait for successful distribute run
id: find_run
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
env:
EXPECTED_RUN_NUMBER: ${{ steps.parse_tag.outputs.run_number }}
TARGET_COMMIT_SHA: ${{ steps.parse_tag.outputs.commit_sha }}
DISTRIBUTE_RUN_ID: ${{ inputs.distribute_run_id || '' }}
with:
script: |
const owner = context.repo.owner;
const repo = context.repo.repo;
const workflowId = 'distribute.yml';
const headSha = process.env.TARGET_COMMIT_SHA;
const expectedRunNumber = Number(process.env.EXPECTED_RUN_NUMBER);
const distributeRunId = process.env.DISTRIBUTE_RUN_ID.trim();
if (
!Number.isFinite(expectedRunNumber) ||
!Number.isInteger(expectedRunNumber)
) {
core.setFailed(
`Invalid EXPECTED_RUN_NUMBER: "${process.env.EXPECTED_RUN_NUMBER}". ` +
'The release tag must contain a numeric Distribute run number.'
);
return;
}
function validateRun(run) {
if (run.run_number !== expectedRunNumber) {
core.setFailed(
`Distribute run ${run.id} is #${run.run_number}, expected #${expectedRunNumber}.`
);
return false;
}
if (run.head_sha !== headSha) {
core.setFailed(
`Distribute run #${run.run_number} points to ${run.head_sha}, expected ${headSha}.`
);
return false;
}
if (run.head_branch !== 'main') {
core.setFailed(
`Distribute run #${run.run_number} is for ${run.head_branch}, expected main.`
);
return false;
}
if (run.path !== '.github/workflows/distribute.yml') {
core.setFailed(
`Run #${run.run_number} used ${run.path}, expected .github/workflows/distribute.yml.`
);
return false;
}
return true;
}
if (distributeRunId) {
const runId = Number(distributeRunId);
if (!Number.isFinite(runId) || !Number.isInteger(runId)) {
core.setFailed(`Invalid DISTRIBUTE_RUN_ID: "${distributeRunId}".`);
return;
}
const { data: run } = await github.request(
'GET /repos/{owner}/{repo}/actions/runs/{run_id}',
{
owner,
repo,
run_id: runId,
}
);
if (!validateRun(run)) {
return;
}
if (run.conclusion !== 'success') {
core.setFailed(
`Distribute run #${run.run_number} concluded with ${run.conclusion}.`
);
return;
}
core.info(`Using Distribute run #${run.run_number} (${run.html_url})`);
core.setOutput('run_id', String(run.id));
core.setOutput('run_url', run.html_url);
return;
}
const pollIntervalMs = 60 * 1000;
const deadline = Date.now() + (30 * 60 * 1000);
while (Date.now() < deadline) {
const runs = await github.paginate(
github.rest.actions.listWorkflowRuns,
{
owner,
repo,
workflow_id: workflowId,
branch: 'main',
per_page: 100,
},
(response, done) => {
const workflowRuns = response.data.workflow_runs ?? [];
if (
workflowRuns.some((run) => run.run_number === expectedRunNumber) ||
workflowRuns.every((run) => run.run_number < expectedRunNumber)
) {
done();
}
return workflowRuns;
}
);
core.info(
`Checked ${runs.length} Distribute runs on main. ` +
`Newest run numbers: ${runs.slice(0, 5).map((run) => run.run_number).join(', ')}`
);
const runWithExpectedNumber = [...runs]
.sort((lhs, rhs) => new Date(rhs.created_at) - new Date(lhs.created_at))
.find((run) => run.run_number === expectedRunNumber);
const matchingRun = runWithExpectedNumber?.head_sha === headSha ? runWithExpectedNumber : undefined;
if (runWithExpectedNumber && !matchingRun) {
core.setFailed(
`Distribute run #${expectedRunNumber} points to ` +
`${runWithExpectedNumber.head_sha}, expected ${headSha}.`
);
return;
}
if (matchingRun?.conclusion === 'success') {
core.info(
`Using successful Distribute run #${matchingRun.run_number} (${matchingRun.html_url})`
);
core.setOutput('run_id', String(matchingRun.id));
core.setOutput('run_url', matchingRun.html_url);
return;
}
if (matchingRun && matchingRun.status !== 'completed') {
core.info(
`Waiting for Distribute run #${matchingRun.run_number} (${matchingRun.status})`
);
await new Promise((resolve) => setTimeout(resolve, pollIntervalMs));
continue;
}
if (matchingRun) {
core.setFailed(
`Distribute run #${matchingRun.run_number} for ${headSha} concluded with ${matchingRun.conclusion}.`
);
return;
}
core.info(
`No Distribute run #${expectedRunNumber} found for ${headSha} on main yet.`
);
await new Promise((resolve) => setTimeout(resolve, pollIntervalMs));
}
core.setFailed(
`Timed out waiting for successful Distribute run #${expectedRunNumber} for ${headSha} on main.`
);
- name: Resolve artifact
id: find_artifact
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
env:
DISTRIBUTE_RUN_ID: ${{ steps.find_run.outputs.run_id }}
with:
script: |
const owner = context.repo.owner;
const repo = context.repo.repo;
const runId = Number(process.env.DISTRIBUTE_RUN_ID);
const { data } = await github.request(
'GET /repos/{owner}/{repo}/actions/runs/{run_id}/artifacts',
{
owner,
repo,
run_id: runId,
per_page: 100,
}
);
const artifact = data.artifacts.find((candidate) => candidate.name === 'mac-developer-id.zip');
if (!artifact) {
core.setFailed(`Artifact mac-developer-id.zip was not found on run ${runId}.`);
return;
}
core.setOutput('artifact_name', artifact.name);
- name: Download release asset
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
run-id: ${{ steps.find_run.outputs.run_id }}
name: ${{ steps.find_artifact.outputs.artifact_name }}
path: release-artifact/extracted
github-token: ${{ secrets.GITHUB_TOKEN }}
- name: Prepare release asset
id: download_asset
run: |
mkdir -p release-assets
extracted_asset_path="$(find release-artifact/extracted -type f -name 'home-assistant-mac.zip' -print -quit)"
if [ -z "$extracted_asset_path" ]; then
echo "Expected release asset was not found inside the artifact archive"
exit 1
fi
asset_path="release-assets/home-assistant-mac.zip"
cp "$extracted_asset_path" "$asset_path"
echo "asset_path=$asset_path" >> "$GITHUB_OUTPUT"
echo "asset_name=$(basename "$asset_path")" >> "$GITHUB_OUTPUT"
- name: Create release or sync existing release
id: release
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
env:
TAG_NAME: ${{ steps.parse_tag.outputs.tag }}
RELEASE_NAME: ${{ steps.parse_tag.outputs.version }} (${{ steps.parse_tag.outputs.display_build }})
TARGET_COMMIT_SHA: ${{ steps.parse_tag.outputs.commit_sha }}
with:
script: |
const owner = context.repo.owner;
const repo = context.repo.repo;
const tag = process.env.TAG_NAME;
const releaseName = process.env.RELEASE_NAME;
const targetCommitish = process.env.TARGET_COMMIT_SHA;
let release;
try {
release = (
await github.request(
'GET /repos/{owner}/{repo}/releases/tags/{tag}',
{
owner,
repo,
tag,
}
)
).data;
release = (
await github.request(
'PATCH /repos/{owner}/{repo}/releases/{release_id}',
{
owner,
repo,
release_id: release.id,
name: releaseName,
target_commitish: targetCommitish,
// Preserve the current prerelease flag so reruns don't undo a manual promotion.
prerelease: release.prerelease,
}
)
).data;
} catch (error) {
if (error.status !== 404) {
throw error;
}
release = (
await github.request(
'POST /repos/{owner}/{repo}/releases',
{
owner,
repo,
tag_name: tag,
target_commitish: targetCommitish,
name: releaseName,
prerelease: true,
generate_release_notes: true,
}
)
).data;
}
core.setOutput('release_id', String(release.id));
core.setOutput('release_url', release.html_url);
core.setOutput('upload_url', release.upload_url);
- name: Upload macOS asset
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
env:
ASSET_PATH: ${{ steps.download_asset.outputs.asset_path }}
ASSET_NAME: ${{ steps.download_asset.outputs.asset_name }}
RELEASE_ID: ${{ steps.release.outputs.release_id }}
UPLOAD_URL: ${{ steps.release.outputs.upload_url }}
with:
script: |
const fs = require('fs');
const owner = context.repo.owner;
const repo = context.repo.repo;
const releaseId = Number(process.env.RELEASE_ID);
const assetPath = process.env.ASSET_PATH;
const assetName = process.env.ASSET_NAME;
const assetSize = fs.statSync(assetPath).size;
const uploadUrl = process.env.UPLOAD_URL.replace(
'{?name,label}',
`?name=${encodeURIComponent(assetName)}`
);
const { data: assets } = await github.request(
'GET /repos/{owner}/{repo}/releases/{release_id}/assets',
{
owner,
repo,
release_id: releaseId,
per_page: 100,
}
);
const existingAsset = assets.find((asset) => asset.name === assetName);
if (existingAsset) {
await github.request(
'DELETE /repos/{owner}/{repo}/releases/assets/{asset_id}',
{
owner,
repo,
asset_id: existingAsset.id,
}
);
}
await github.request({
method: 'POST',
url: uploadUrl,
headers: {
'content-type': 'application/zip',
'content-length': assetSize,
},
data: fs.createReadStream(assetPath),
});
- name: Write summary
env:
VERSION: ${{ steps.parse_tag.outputs.version }}
DISPLAY_BUILD: ${{ steps.parse_tag.outputs.display_build }}
DISTRIBUTE_RUN_URL: ${{ steps.find_run.outputs.run_url }}
RELEASE_URL: ${{ steps.release.outputs.release_url }}
run: |
{
echo "## macOS release created"
echo
echo "- Version: ${VERSION} (${DISPLAY_BUILD})"
echo "- Distribution run: ${DISTRIBUTE_RUN_URL}"
echo "- Release: ${RELEASE_URL}"
} >> "$GITHUB_STEP_SUMMARY"