Skip to content

chore(desktop): bump version to 0.3.4 (#200) #19

chore(desktop): bump version to 0.3.4 (#200)

chore(desktop): bump version to 0.3.4 (#200) #19

Workflow file for this run

name: Desktop Release
on:
push:
tags: ['desktop-v*']
workflow_call:
inputs:
publish_nightly:
description: Explicit permission for the reusable workflow to publish Nightly
required: true
type: boolean
workflow_dispatch:
inputs:
channel:
description: Update feed to rehearse without publishing
required: true
default: stable
type: choice
options: [stable, beta, nightly]
permissions:
contents: read
concurrency:
group: desktop-release-${{ github.ref }}
cancel-in-progress: false
# Stable and Beta publish from reviewed desktop-v* package versions. Nightly is
# called only by the default-branch Nightly workflow and derives one version per
# main commit. A manual run remains a non-publishing release rehearsal.
jobs:
# Create one draft before either platform uploads. It stays private until both
# jobs and the final downloaded-asset validation succeed.
prepare:
runs-on: ubuntu-latest
outputs:
version: ${{ steps.resolve.outputs.version }}
tag: ${{ steps.resolve.outputs.tag }}
channel: ${{ steps.resolve.outputs.channel }}
mac_manifest: ${{ steps.resolve.outputs.mac_manifest }}
win_manifest: ${{ steps.resolve.outputs.win_manifest }}
publish: ${{ steps.resolve.outputs.publish }}
prerelease: ${{ steps.resolve.outputs.prerelease }}
build: ${{ steps.release.outputs.build }}
steps:
- name: Checkout
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # pinned from v4
with:
fetch-depth: 0
persist-credentials: false
- name: Resolve the desktop release
id: resolve
shell: bash
env:
EVENT_NAME: ${{ inputs.publish_nightly && 'workflow_call' || github.event_name }}
PUBLISH_NIGHTLY: ${{ inputs.publish_nightly }}
REQUESTED_CHANNEL: ${{ inputs.channel }}
TAG_NAME: ${{ github.ref_name }}
run: |
set -euo pipefail
package_version="$(node -p 'require("./apps/desktop/package.json").version')"
commit_count="$(git rev-list --count HEAD)"
node apps/desktop/scripts/desktop-release.mjs resolve \
"$EVENT_NAME" "$package_version" "${REQUESTED_CHANNEL:-}" "${TAG_NAME:-}" "$commit_count" \
"${PUBLISH_NIGHTLY:-false}"
# A release built from a commit that never landed on main ships code no
# review gate ever saw. `compare` reports `identical` or `behind` when the
# commit is an ancestor of main, and `diverged`/`ahead` when it is not.
- name: Require the release commit to be on main
if: steps.resolve.outputs.publish == 'true'
shell: bash
env:
GH_TOKEN: ${{ github.token }}
run: |
set -euo pipefail
status="$(gh api "repos/${GITHUB_REPOSITORY}/compare/main...${GITHUB_SHA}" --jq '.status')"
case "$status" in
identical|behind) echo "${GITHUB_SHA} is on main (${status})." ;;
*)
echo "::error::${GITHUB_SHA} is not on main (compare status: ${status})." >&2
exit 1 ;;
esac
- name: Mint releases-repo token
if: steps.resolve.outputs.publish == 'true'
id: releases_token
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # pinned from v3.2.0
with:
app-id: ${{ secrets.DESKTOP_RELEASES_APP_ID }}
private-key: ${{ secrets.DESKTOP_RELEASES_APP_PRIVATE_KEY }}
owner: PyModel
repositories: pythinker-desktop-releases
permission-contents: write
- name: Prepare the draft release or rehearsal
id: release
shell: bash
env:
GH_TOKEN: ${{ steps.releases_token.outputs.token }}
PUBLISH: ${{ steps.resolve.outputs.publish }}
PRERELEASE: ${{ steps.resolve.outputs.prerelease }}
RELEASE_CHANNEL: ${{ steps.resolve.outputs.channel }}
RELEASE_TAG: ${{ steps.resolve.outputs.tag }}
RELEASE_REPO: PyModel/pythinker-desktop-releases
SOURCE_URL: ${{ github.server_url }}/${{ github.repository }}/commit/${{ github.sha }}
run: |
set -euo pipefail
if [ "$PUBLISH" != 'true' ]; then
echo 'build=true' >> "$GITHUB_OUTPUT"
echo "Rehearsing ${RELEASE_CHANNEL} ${RELEASE_TAG}; nothing will be published."
exit 0
fi
# Drafts are safe to resume. A published release is immutable here:
# platform uploads use --clobber and must never replace live assets.
if release_json="$(gh release view "$RELEASE_TAG" --repo "$RELEASE_REPO" --json body,isDraft,isPrerelease 2>/dev/null)"; then
is_draft="$(jq -r '.isDraft' <<< "$release_json")"
is_prerelease="$(jq -r '.isPrerelease' <<< "$release_json")"
if ! jq -e --arg source_url "$SOURCE_URL" '(.body // "") | contains($source_url)' >/dev/null <<< "$release_json"; then
echo "::error::Release ${RELEASE_TAG} belongs to a different source commit." >&2
exit 1
fi
if [ "$is_draft" != 'true' ]; then
if [ "$RELEASE_CHANNEL" = 'nightly' ] && [ "$is_prerelease" = 'true' ]; then
echo 'build=false' >> "$GITHUB_OUTPUT"
echo "Nightly release ${RELEASE_TAG} already exists; no new main commit to publish."
exit 0
fi
echo "::error::Release ${RELEASE_TAG} already exists and is published; refusing to replace live assets." >&2
exit 1
fi
if [ "$is_prerelease" != "$PRERELEASE" ]; then
echo "::error::Draft ${RELEASE_TAG} has the wrong prerelease state." >&2
exit 1
fi
echo 'build=true' >> "$GITHUB_OUTPUT"
echo "Draft release ${RELEASE_TAG} already exists; resuming it."
exit 0
fi
args=(
"$RELEASE_TAG"
--repo "$RELEASE_REPO"
--draft
--title "$RELEASE_TAG"
--notes "Pythinker Desktop ${RELEASE_TAG#v} (${RELEASE_CHANNEL} channel), built from ${SOURCE_URL}."
)
if [ "$PRERELEASE" = 'true' ]; then args+=(--prerelease); fi
gh release create "${args[@]}"
echo 'build=true' >> "$GITHUB_OUTPUT"
echo "Created draft release ${RELEASE_TAG}."
mac:
needs: prepare
if: needs.prepare.outputs.build == 'true'
runs-on: macos-15
steps:
- name: Checkout
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # pinned from v4
with:
fetch-depth: 0
persist-credentials: false
- uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # pinned from v6
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # pinned from v7.0.0
with:
node-version-file: .nvmrc
cache: pnpm
- run: pnpm install --frozen-lockfile
- name: Configure desktop release
shell: bash
env:
DESKTOP_CHANNEL: ${{ needs.prepare.outputs.channel }}
DESKTOP_VERSION: ${{ needs.prepare.outputs.version }}
run: node apps/desktop/scripts/desktop-release.mjs configure apps/desktop/package.json "$DESKTOP_VERSION" "$DESKTOP_CHANNEL"
- name: Build workspace
run: pnpm --workspace-root run build
- name: Stage desktop runtime
working-directory: apps/desktop
run: node --import tsx scripts/stage-runtime.ts
# An unset GitHub secret interpolates to an empty string, and
# electron-builder resolves an empty CSC_LINK as a certificate path
# (path.resolve(appDir, '') === appDir), failing with "not a file".
# Export only the variables that carry a value.
- name: Resolve macOS signing credentials
shell: bash
env:
IN_CSC_LINK: ${{ secrets.MAC_CSC_LINK }}
IN_CSC_KEY_PASSWORD: ${{ secrets.MAC_CSC_KEY_PASSWORD }}
IN_CSC_NAME: ${{ secrets.MAC_CSC_NAME }}
IN_APPLE_ID: ${{ secrets.APPLE_ID }}
IN_APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }}
IN_APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
IN_APPLE_API_KEY_ID: ${{ secrets.APPLE_API_KEY_ID }}
IN_APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }}
IN_APPLE_API_KEY_P8: ${{ secrets.APPLE_API_KEY_P8 }}
run: |
# A fixed heredoc marker lets a credential that happens to contain that
# line close its own value early and turn the rest into environment
# entries. Draw the delimiter at random so no secret can carry it.
delimiter="EOF_$(openssl rand -hex 16)"
for name in CSC_LINK CSC_KEY_PASSWORD CSC_NAME APPLE_ID APPLE_APP_SPECIFIC_PASSWORD APPLE_TEAM_ID APPLE_API_KEY_ID APPLE_API_ISSUER; do
input="IN_${name}"
value="${!input:-}"
if [ -n "$value" ]; then printf '%s<<%s\n%s\n%s\n' "$name" "$delimiter" "$value" "$delimiter" >> "$GITHUB_ENV"; fi
done
# The App Store Connect key is held as base64 because it is a file, not a
# string. notarytool and electron-builder both want a path, so materialize
# it outside the workspace to keep it out of the packaged app.
if [ -n "${IN_APPLE_API_KEY_P8:-}" ]; then
key_path="${RUNNER_TEMP}/AuthKey.p8"
printf '%s' "$IN_APPLE_API_KEY_P8" | base64 -d > "$key_path"
chmod 600 "$key_path"
echo "APPLE_API_KEY=${key_path}" >> "$GITHUB_ENV"
fi
if [ -z "${IN_CSC_LINK:-}" ]; then
echo 'CSC_IDENTITY_AUTO_DISCOVERY=false' >> "$GITHUB_ENV"
echo 'No macOS signing certificate configured; building unsigned.'
fi
# A build that quietly produces an unsigned app is worse than a failed
# one: macOS rejects unsigned updates, so it ships a release users cannot
# install or update from. Manual runs are held to the same bar, which is
# what makes them a rehearsal rather than a smoke test.
- name: Require signing
working-directory: apps/desktop
run: node --import tsx scripts/assert-release-signing.ts
- name: Package desktop release
working-directory: apps/desktop
run: pnpm exec electron-builder --mac dmg zip --publish never
# electron-builder notarizes and staples the .app but never the disk
# image around it; a quarantined, unnotarized DMG is what Gatekeeper
# reports as "damaged". Stapling changes the DMG bytes, so the same
# script also rewrites its latest-mac.yml entry and drops the stale
# blockmap, keeping the update manifest verification below honest.
- name: Notarize and staple macOS DMG
working-directory: apps/desktop
run: node --import tsx scripts/finalize-mac-artifacts.ts dist
- name: Verify macOS update artifacts
shell: bash
env:
DESKTOP_CHANNEL: ${{ needs.prepare.outputs.channel }}
DESKTOP_VERSION: ${{ needs.prepare.outputs.version }}
UPDATE_MANIFEST: ${{ needs.prepare.outputs.mac_manifest }}
run: |
set -euo pipefail
app_bundle="$(find apps/desktop/dist -maxdepth 2 -type d -name '*.app' -print -quit)"
if [ -z "$app_bundle" ]; then
echo 'macOS application bundle not found' >&2
exit 1
fi
test -f "$app_bundle/Contents/Resources/app-update.yml"
node --import tsx apps/desktop/scripts/verify-update-manifest.ts mac apps/desktop/dist "$DESKTOP_VERSION" "$DESKTOP_CHANNEL"
test -f "apps/desktop/dist/${UPDATE_MANIFEST}"
- name: Verify macOS signatures and notarization
shell: bash
run: |
set -euo pipefail
verify_app() {
codesign --verify --deep --strict --verbose=2 "$1"
spctl --assess --type execute --verbose=2 "$1"
}
app_bundle="$(find apps/desktop/dist -maxdepth 2 -type d -name '*.app' -print -quit)"
zip_path="$(find apps/desktop/dist -maxdepth 1 -type f -name '*-mac.zip' -print -quit)"
dmg_path="$(find apps/desktop/dist -maxdepth 1 -type f -name '*.dmg' -print -quit)"
if [ -z "$app_bundle" ] || [ -z "$zip_path" ] || [ -z "$dmg_path" ]; then
echo 'Complete macOS release artifacts were not found' >&2
exit 1
fi
verify_app "$app_bundle"
zip_dir="${RUNNER_TEMP}/macos-zip"
mkdir "$zip_dir"
ditto -x -k "$zip_path" "$zip_dir"
zip_app="$(find "$zip_dir" -maxdepth 2 -type d -name '*.app' -print -quit)"
if [ -z "$zip_app" ]; then
echo 'macOS ZIP does not contain an application bundle' >&2
exit 1
fi
verify_app "$zip_app"
hdiutil verify "$dmg_path"
mount_point="${RUNNER_TEMP}/macos-dmg"
mkdir "$mount_point"
hdiutil attach "$dmg_path" -readonly -nobrowse -mountpoint "$mount_point"
cleanup_mount() { hdiutil detach "$mount_point" || true; }
trap cleanup_mount EXIT
dmg_app="$(find "$mount_point" -maxdepth 2 -type d -name '*.app' -print -quit)"
if [ -z "$dmg_app" ]; then
echo 'macOS DMG does not contain an application bundle' >&2
exit 1
fi
verify_app "$dmg_app"
xcrun stapler validate "$dmg_app"
cleanup_mount
trap - EXIT
# The image carries its own ticket (see the notarize step above);
# Gatekeeper must accept it exactly as a user's browser download.
xcrun stapler validate "$dmg_path"
spctl --assess --type open --context context:primary-signature --verbose=2 "$dmg_path"
- name: Mint releases-repo token
if: needs.prepare.outputs.publish == 'true'
id: releases_token
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # pinned from v3.2.0
with:
app-id: ${{ secrets.DESKTOP_RELEASES_APP_ID }}
private-key: ${{ secrets.DESKTOP_RELEASES_APP_PRIVATE_KEY }}
owner: PyModel
repositories: pythinker-desktop-releases
permission-contents: write
- name: Upload verified macOS release assets
if: needs.prepare.outputs.publish == 'true'
shell: bash
env:
GH_TOKEN: ${{ steps.releases_token.outputs.token }}
UPDATE_MANIFEST: ${{ needs.prepare.outputs.mac_manifest }}
RELEASE_TAG: ${{ needs.prepare.outputs.tag }}
RELEASE_REPO: PyModel/pythinker-desktop-releases
run: |
set -euo pipefail
shopt -s nullglob
assets=(apps/desktop/dist/*.dmg apps/desktop/dist/*-mac.zip apps/desktop/dist/*.blockmap "apps/desktop/dist/${UPDATE_MANIFEST}")
gh release upload "$RELEASE_TAG" "${assets[@]}" --repo "$RELEASE_REPO" --clobber
- name: Upload macOS artifacts for manual runs
if: needs.prepare.outputs.publish != 'true'
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # pinned from v7
with:
name: desktop-macos
path: |
apps/desktop/dist/*.dmg
apps/desktop/dist/*.zip
apps/desktop/dist/*.yml
if-no-files-found: error
windows:
needs: prepare
if: needs.prepare.outputs.build == 'true'
runs-on: windows-latest
steps:
- name: Checkout
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # pinned from v4
with:
fetch-depth: 0
persist-credentials: false
- uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # pinned from v6
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # pinned from v7.0.0
with:
node-version-file: .nvmrc
cache: pnpm
- run: pnpm install --frozen-lockfile
- name: Configure desktop release
shell: bash
env:
DESKTOP_CHANNEL: ${{ needs.prepare.outputs.channel }}
DESKTOP_VERSION: ${{ needs.prepare.outputs.version }}
run: node apps/desktop/scripts/desktop-release.mjs configure apps/desktop/package.json "$DESKTOP_VERSION" "$DESKTOP_CHANNEL"
- name: Build workspace
run: pnpm --workspace-root run build
- name: Stage desktop runtime
working-directory: apps/desktop
run: node --import tsx scripts/stage-runtime.ts
# Only non-empty WIN_CSC_* signing secrets are exported. Without them the
# artifacts are unsigned and installers trigger a SmartScreen warning, so
# the credential check below fails the run rather than shipping one.
- name: Resolve Windows signing credentials
shell: bash
env:
IN_WIN_CSC_LINK: ${{ secrets.WIN_CSC_LINK }}
IN_WIN_CSC_KEY_PASSWORD: ${{ secrets.WIN_CSC_KEY_PASSWORD }}
IN_WINDOWS_SIGNING_PUBLISHER_NAME: ${{ secrets.WINDOWS_SIGNING_PUBLISHER_NAME }}
run: |
delimiter="EOF_$(openssl rand -hex 16)"
for name in WIN_CSC_LINK WIN_CSC_KEY_PASSWORD WINDOWS_SIGNING_PUBLISHER_NAME; do
input="IN_${name}"
value="${!input:-}"
if [ -n "$value" ]; then printf '%s<<%s\n%s\n%s\n' "$name" "$delimiter" "$value" "$delimiter" >> "$GITHUB_ENV"; fi
done
- name: Resolve Azure signing configuration
shell: bash
env:
IN_AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }}
IN_AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}
IN_AZURE_CLIENT_SECRET: ${{ secrets.AZURE_CLIENT_SECRET }}
IN_AZURE_SIGNING_ENDPOINT: ${{ secrets.AZURE_SIGNING_ENDPOINT }}
IN_AZURE_SIGNING_ACCOUNT: ${{ secrets.AZURE_SIGNING_ACCOUNT }}
IN_AZURE_SIGNING_CERT_PROFILE: ${{ secrets.AZURE_SIGNING_CERT_PROFILE }}
IN_AZURE_SIGNING_PUBLISHER_NAME: ${{ secrets.AZURE_SIGNING_PUBLISHER_NAME }}
run: |
delimiter="EOF_$(openssl rand -hex 16)"
for name in AZURE_TENANT_ID AZURE_CLIENT_ID AZURE_CLIENT_SECRET AZURE_SIGNING_ENDPOINT AZURE_SIGNING_ACCOUNT AZURE_SIGNING_CERT_PROFILE AZURE_SIGNING_PUBLISHER_NAME; do
input="IN_${name}"
value="${!input:-}"
if [ -n "$value" ]; then printf '%s<<%s\n%s\n%s\n' "$name" "$delimiter" "$value" "$delimiter" >> "$GITHUB_ENV"; fi
done
- name: Require signing
working-directory: apps/desktop
run: node --import tsx scripts/assert-windows-release-signing.ts
- name: Package desktop release
working-directory: apps/desktop
run: node --import tsx scripts/package-win.ts --publish never
- name: Verify Windows update artifacts
shell: bash
env:
DESKTOP_CHANNEL: ${{ needs.prepare.outputs.channel }}
DESKTOP_VERSION: ${{ needs.prepare.outputs.version }}
UPDATE_MANIFEST: ${{ needs.prepare.outputs.win_manifest }}
run: |
set -euo pipefail
test -f apps/desktop/dist/win-unpacked/resources/app-update.yml
node --import tsx apps/desktop/scripts/verify-update-manifest.ts win apps/desktop/dist "$DESKTOP_VERSION" "$DESKTOP_CHANNEL"
test -f "apps/desktop/dist/${UPDATE_MANIFEST}"
- name: Verify Windows release signatures
working-directory: apps/desktop
run: node --import tsx scripts/verify-windows-signatures.ts
- name: Mint releases-repo token
if: needs.prepare.outputs.publish == 'true'
id: releases_token
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # pinned from v3.2.0
with:
app-id: ${{ secrets.DESKTOP_RELEASES_APP_ID }}
private-key: ${{ secrets.DESKTOP_RELEASES_APP_PRIVATE_KEY }}
owner: PyModel
repositories: pythinker-desktop-releases
permission-contents: write
- name: Upload verified Windows release assets
if: needs.prepare.outputs.publish == 'true'
shell: bash
env:
GH_TOKEN: ${{ steps.releases_token.outputs.token }}
UPDATE_MANIFEST: ${{ needs.prepare.outputs.win_manifest }}
RELEASE_TAG: ${{ needs.prepare.outputs.tag }}
RELEASE_REPO: PyModel/pythinker-desktop-releases
run: |
set -euo pipefail
shopt -s nullglob
assets=(apps/desktop/dist/*.exe apps/desktop/dist/*.blockmap "apps/desktop/dist/${UPDATE_MANIFEST}")
gh release upload "$RELEASE_TAG" "${assets[@]}" --repo "$RELEASE_REPO" --clobber
- name: Upload Windows artifacts for manual runs
if: needs.prepare.outputs.publish != 'true'
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # pinned from v7
with:
name: desktop-windows
path: |
apps/desktop/dist/*.exe
apps/desktop/dist/*.yml
if-no-files-found: error
# Publishing last, and only once both platforms uploaded, is what makes the
# release atomic. A platform that fails leaves a draft nobody can download,
# which is recoverable by re-running that job; publishing per-platform instead
# would leave a live release missing an operating system.
publish:
if: needs.prepare.outputs.publish == 'true' && needs.prepare.outputs.build == 'true'
needs: [prepare, mac, windows]
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # pinned from v4
with:
persist-credentials: false
- uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # pinned from v6
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # pinned from v7.0.0
with:
node-version-file: .nvmrc
cache: pnpm
- run: pnpm install --frozen-lockfile
- name: Mint releases-repo token
id: releases_token
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # pinned from v3.2.0
with:
app-id: ${{ secrets.DESKTOP_RELEASES_APP_ID }}
private-key: ${{ secrets.DESKTOP_RELEASES_APP_PRIVATE_KEY }}
owner: PyModel
repositories: pythinker-desktop-releases
permission-contents: write
- name: Download and validate every release asset
shell: bash
env:
GH_TOKEN: ${{ steps.releases_token.outputs.token }}
DESKTOP_CHANNEL: ${{ needs.prepare.outputs.channel }}
RELEASE_TAG: ${{ needs.prepare.outputs.tag }}
RELEASE_REPO: PyModel/pythinker-desktop-releases
DESKTOP_VERSION: ${{ needs.prepare.outputs.version }}
run: |
set -euo pipefail
mkdir release-assets
gh release download "$RELEASE_TAG" --repo "$RELEASE_REPO" --dir release-assets
node --import tsx apps/desktop/scripts/verify-update-manifest.ts mac release-assets "$DESKTOP_VERSION" "$DESKTOP_CHANNEL"
node --import tsx apps/desktop/scripts/verify-update-manifest.ts win release-assets "$DESKTOP_VERSION" "$DESKTOP_CHANNEL"
- name: Publish the release
shell: bash
env:
GH_TOKEN: ${{ steps.releases_token.outputs.token }}
EXPECTED_PRERELEASE: ${{ needs.prepare.outputs.prerelease }}
RELEASE_TAG: ${{ needs.prepare.outputs.tag }}
RELEASE_REPO: PyModel/pythinker-desktop-releases
run: |
set -euo pipefail
gh release edit "$RELEASE_TAG" --repo "$RELEASE_REPO" --draft=false
state="$(gh release view "$RELEASE_TAG" --repo "$RELEASE_REPO" --json isDraft,isPrerelease --jq '[.isDraft, .isPrerelease] | @tsv')"
IFS=$'\t' read -r is_draft is_prerelease <<< "$state"
if [ "$is_draft" != 'false' ] || [ "$is_prerelease" != "$EXPECTED_PRERELEASE" ]; then
echo "::error::Published ${RELEASE_TAG} has the wrong release state." >&2
exit 1
fi
echo "Published ${RELEASE_TAG}."