Skip to content
Open
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
353 changes: 353 additions & 0 deletions .github/workflows/release_macos.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,353 @@
name: Release macOS

on:
push:
tags:
- 'release/*/*'

permissions:
actions: read
contents: write

concurrency:
group: macos-release-${{ github.ref_name }}
cancel-in-progress: false

jobs:
release:
runs-on: ubuntu-latest
timeout-minutes: 45
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
fetch-depth: 0

- name: Parse release tag
id: parse_tag
run: |
tag="${GITHUB_REF_NAME}"

case "$tag" in
release/*/*) ;;
*)
echo "Unexpected tag format: $tag"
exit 1
;;
esac

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:
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 ${GITHUB_REF_NAME} does not point to a commit on origin/main"
exit 1
fi
Comment thread
bgoncal marked this conversation as resolved.

- 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 }}
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);
Comment thread
bgoncal marked this conversation as resolved.

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;
}

const pollIntervalMs = 60 * 1000;
const deadline = Date.now() + (30 * 60 * 1000);

while (Date.now() < deadline) {
const { data } = await github.request(
'GET /repos/{owner}/{repo}/actions/workflows/{workflow_id}/runs',
{
owner,
repo,
workflow_id: workflowId,
head_sha: headSha,
branch: 'main',
per_page: 20,
}
);

const runs = [...data.workflow_runs].sort((lhs, rhs) =>
new Date(rhs.created_at) - new Date(lhs.created_at)
);
const matchingRun = runs.find((run) => run.run_number === expectedRunNumber);
Comment on lines +92 to +107
Copy link

Copilot AI Apr 23, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

per_page: 20 when listing Distribute workflow runs can cause this polling loop to never find the expected run if there are more than 20 runs for the same head_sha (e.g., repeated reruns). Consider requesting a larger page size (up to 100) and/or using Octokit pagination (github.paginate) to search all pages until the expected run_number is found or the deadline is hit.

Suggested change
const { data } = await github.request(
'GET /repos/{owner}/{repo}/actions/workflows/{workflow_id}/runs',
{
owner,
repo,
workflow_id: workflowId,
head_sha: headSha,
branch: 'main',
per_page: 20,
}
);
const runs = [...data.workflow_runs].sort((lhs, rhs) =>
new Date(rhs.created_at) - new Date(lhs.created_at)
);
const matchingRun = runs.find((run) => run.run_number === expectedRunNumber);
const runs = await github.paginate(
github.rest.actions.listWorkflowRuns,
{
owner,
repo,
workflow_id: workflowId,
head_sha: headSha,
branch: 'main',
per_page: 100,
},
(response, done) => {
const workflowRuns = response.data.workflow_runs ?? [];
if (workflowRuns.some((run) => run.run_number === expectedRunNumber)) {
done();
}
return workflowRuns;
}
);
const matchingRun = [...runs]
.sort((lhs, rhs) => new Date(rhs.created_at) - new Date(lhs.created_at))
.find((run) => run.run_number === expectedRunNumber);

Copilot uses AI. Check for mistakes.

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} 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);
core.setOutput('artifact_url', artifact.archive_download_url);

- name: Download release asset
id: download_asset
env:
ARTIFACT_URL: ${{ steps.find_artifact.outputs.artifact_url }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
mkdir -p release-artifact/extracted release-assets
redirect_headers="$(mktemp)"
trap 'rm -f "$redirect_headers"' EXIT

curl --fail --silent --show-error \
--dump-header "$redirect_headers" \
--header "Authorization: Bearer ${GITHUB_TOKEN}" \
--header "Accept: application/vnd.github+json" \
"${ARTIFACT_URL}" \
--output /dev/null

artifact_download_url="$(
awk 'BEGIN { IGNORECASE = 1 } /^location:/ { sub(/\r$/, "", $2); print $2; exit }' \
"$redirect_headers"
)"

if [ -z "$artifact_download_url" ]; then
echo "Failed to resolve artifact download redirect URL"
exit 1
fi

curl --fail --location \
--output release-artifact/mac-developer-id.zip \
"$artifact_download_url"

unzip -o release-artifact/mac-developer-id.zip -d release-artifact/extracted

Comment on lines +172 to +206
Copy link

Copilot AI Apr 23, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This step manually resolves the artifact download redirect by parsing response headers. GitHub provides supported tooling for this (e.g., actions/download-artifact with run-id + name), which avoids brittle header parsing and reduces the chance of breakage if GitHub changes redirect behavior/headers.

Suggested change
core.setOutput('artifact_url', artifact.archive_download_url);
- name: Download release asset
id: download_asset
env:
ARTIFACT_URL: ${{ steps.find_artifact.outputs.artifact_url }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
mkdir -p release-artifact/extracted release-assets
redirect_headers="$(mktemp)"
trap 'rm -f "$redirect_headers"' EXIT
curl --fail --silent --show-error \
--dump-header "$redirect_headers" \
--header "Authorization: Bearer ${GITHUB_TOKEN}" \
--header "Accept: application/vnd.github+json" \
"${ARTIFACT_URL}" \
--output /dev/null
artifact_download_url="$(
awk 'BEGIN { IGNORECASE = 1 } /^location:/ { sub(/\r$/, "", $2); print $2; exit }' \
"$redirect_headers"
)"
if [ -z "$artifact_download_url" ]; then
echo "Failed to resolve artifact download redirect URL"
exit 1
fi
curl --fail --location \
--output release-artifact/mac-developer-id.zip \
"$artifact_download_url"
unzip -o release-artifact/mac-developer-id.zip -d release-artifact/extracted
- name: Download release artifact
uses: actions/download-artifact@v4
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

Copilot uses AI. Check for mistakes.
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,
Comment thread
bgoncal marked this conversation as resolved.
Comment thread
bgoncal marked this conversation as resolved.
}
)
).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 uploadUrl = process.env.UPLOAD_URL.replace(
'{?name,label}',
`?name=${encodeURIComponent(assetName)}`
);
const data = fs.readFileSync(assetPath);
Copy link

Copilot AI Apr 23, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

fs.readFileSync(assetPath) loads the entire zip into memory before uploading. Release assets can be large, so this can cause unnecessary memory pressure or failures on the runner. Prefer streaming upload (e.g., fs.createReadStream) or a purpose-built release upload action/client that supports streaming.

Copilot uses AI. Check for mistakes.

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': data.length,
},
data,
});

- 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"
Loading