Skip to content

[codex] restore appearance after failed Windows startup (#357) #56

[codex] restore appearance after failed Windows startup (#357)

[codex] restore appearance after failed Windows startup (#357) #56

Workflow file for this run

name: Release
# This workflow deliberately does NOT gate on `workflow_run: [CI]`. The guard
# job below decides whether a main push is a real version bump by diffing
# macos/VERSION against `github.event.before`, and a `workflow_run` payload
# carries no `before` field. Chaining CI would therefore disable the gate that
# AGENTS.md requires against rebuilding or overwriting a Release when the
# version did not change, and would break the idempotent `workflow_dispatch`
# retry of a single tag. The checks CI performs are instead repeated by the
# `regressions` job below, run against the exact release commit rather than
# whatever CI last happened to observe.
on:
push:
branches:
- main
workflow_dispatch:
permissions:
contents: read
concurrency:
group: release-main
cancel-in-progress: false
jobs:
guard:
name: Prepare release candidate
runs-on: ubuntu-latest
outputs:
version: ${{ steps.version.outputs.version }}
tag: ${{ steps.version.outputs.tag }}
release_sha: ${{ steps.version.outputs.release_sha }}
should_release: ${{ steps.version.outputs.should_release }}
steps:
- name: Check out source
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
with:
# Bind every release attempt to the commit that triggered it. A
# moving main checkout could otherwise package a later push.
ref: ${{ github.sha }}
fetch-depth: 0
persist-credentials: false
- name: Set up Node.js for manifest checks
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with:
node-version: '22'
- name: Match main versions and release state
id: version
shell: bash
env:
GH_TOKEN: ${{ github.token }}
EVENT_NAME: ${{ github.event_name }}
BEFORE_SHA: ${{ github.event.before }}
run: |
set -euo pipefail
event_sha="$(git rev-parse HEAD)"
version="$(tr -d '[:space:]' < macos/VERSION)"
windows_version="$(tr -d '[:space:]' < windows/VERSION)"
package_version="$(node -p "JSON.parse(require('fs').readFileSync('macos/package.json', 'utf8')).version")"
common_version="$(sed -n 's/^SKIN_VERSION=\"\([^\"]*\)\"$/\1/p' macos/scripts/common-macos.sh)"
macos_injector_version="$(sed -n 's/^const SKIN_VERSION = \"\([^\"]*\)\";$/\1/p' macos/scripts/injector.mjs)"
windows_injector_version="$(sed -n 's/^const SKIN_VERSION = \"\([^\"]*\)\";$/\1/p' windows/scripts/injector.mjs)"
for value in "$version" "$windows_version" "$package_version" "$common_version" "$macos_injector_version" "$windows_injector_version"; do
[[ "$value" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]] || {
echo "A platform version is not semantic: $value" >&2
exit 1
}
done
if [[ "$windows_version" != "$version" || "$package_version" != "$version" ||
"$common_version" != "$version" || "$macos_injector_version" != "$version" ||
"$windows_injector_version" != "$version" ]]; then
echo "The six release version sources do not match." >&2
exit 1
fi
tag="v$version"
release_sha="$event_sha"
should_release="true"
version_unchanged="false"
if [[ "$EVENT_NAME" == "push" ]]; then
[[ -n "$BEFORE_SHA" && "$BEFORE_SHA" != "0000000000000000000000000000000000000000" ]] || {
echo "A main push without a predecessor cannot publish." >&2
exit 1
}
previous_version="$(git show "$BEFORE_SHA:macos/VERSION" 2>/dev/null | tr -d '[:space:]' || true)"
if [[ "$previous_version" == "$version" ]]; then
version_unchanged="true"
else
[[ "$previous_version" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]] || {
echo "Previous main version is invalid: $previous_version" >&2
exit 1
}
CURRENT_VERSION="$version" PREVIOUS_VERSION="$previous_version" node <<'NODE'
const current = process.env.CURRENT_VERSION.split(".").map(Number);
const previous = process.env.PREVIOUS_VERSION.split(".").map(Number);
const comparison = current[0] - previous[0] || current[1] - previous[1] || current[2] - previous[2];
if (comparison <= 0) {
console.error("Release version must increase.");
process.exit(1);
}
NODE
fi
fi
tag_sha="$(git ls-remote origin "refs/tags/$tag" | awk 'NR == 1 { print $1 }')"
if [[ -n "$tag_sha" ]]; then
tag_commit="$(git rev-parse "$tag_sha^{commit}" 2>/dev/null || true)"
[[ -n "$tag_commit" ]] || {
echo "Existing $tag is not a commit-backed tag." >&2
exit 1
}
tagged_macos="$(git show "$tag_commit:macos/VERSION" | tr -d '[:space:]')"
tagged_windows="$(git show "$tag_commit:windows/VERSION" | tr -d '[:space:]')"
tagged_package="$(git show "$tag_commit:macos/package.json" | node -e 'let s=""; process.stdin.on("data", (chunk) => s += chunk); process.stdin.on("end", () => process.stdout.write(JSON.parse(s).version));')"
tagged_common="$(git show "$tag_commit:macos/scripts/common-macos.sh" | sed -n 's/^SKIN_VERSION=\"\([^\"]*\)\"$/\1/p')"
tagged_macos_injector="$(git show "$tag_commit:macos/scripts/injector.mjs" | sed -n 's/^const SKIN_VERSION = \"\([^\"]*\)\";$/\1/p')"
tagged_windows_injector="$(git show "$tag_commit:windows/scripts/injector.mjs" | sed -n 's/^const SKIN_VERSION = \"\([^\"]*\)\";$/\1/p')"
[[ "$tagged_macos" == "$version" && "$tagged_windows" == "$version" &&
"$tagged_package" == "$version" && "$tagged_common" == "$version" &&
"$tagged_macos_injector" == "$version" && "$tagged_windows_injector" == "$version" ]] || {
echo "Existing $tag does not contain a consistent $version payload." >&2
exit 1
}
fi
release_state=""
if release_json="$(gh release view "$tag" --json isDraft 2>/dev/null)"; then
release_state="$(jq -r '.isDraft | if . then "draft" else "published" end' <<<"$release_json")"
fi
if [[ "$release_state" == "published" ]]; then
if [[ "$EVENT_NAME" == "workflow_dispatch" || "$version_unchanged" == "true" ]]; then
[[ -n "$tag_sha" ]] || {
echo "Published $tag has no corresponding remote tag." >&2
exit 1
}
should_release="false"
echo "Public $tag already exists; skipping duplicate publication."
else
[[ "$tag_commit" == "$release_sha" ]] || {
echo "Published $tag does not point at $release_sha." >&2
exit 1
}
fi
should_release="false"
elif [[ -n "$tag_sha" ]]; then
if [[ "$version_unchanged" == "true" ]]; then
release_sha="$tag_commit"
echo "Resuming incomplete $tag from its existing tagged commit."
else
[[ "$tag_commit" == "$release_sha" ]] || {
echo "Existing $tag points at $tag_sha, not $release_sha." >&2
exit 1
}
fi
elif [[ "$should_release" == "true" ]]; then
previous_tag="$(git tag --list 'v[0-9]*' --sort=-version:refname | head -n 1 || true)"
if [[ -n "$previous_tag" ]]; then
previous_version="${previous_tag#v}"
CURRENT_VERSION="$version" PREVIOUS_VERSION="$previous_version" node <<'NODE'
const current = process.env.CURRENT_VERSION.split(".").map(Number);
const previous = process.env.PREVIOUS_VERSION.split(".").map(Number);
const comparison = current[0] - previous[0] || current[1] - previous[1] || current[2] - previous[2];
if (comparison <= 0) {
console.error("Release version is not newer than the latest tag.");
process.exit(1);
}
NODE
fi
fi
{
echo "version=$version"
echo "tag=$tag"
echo "release_sha=$release_sha"
echo "should_release=$should_release"
} >> "$GITHUB_OUTPUT"
regressions:
name: Portable release regressions
needs: guard
if: needs.guard.outputs.should_release == 'true'
runs-on: ubuntu-latest
steps:
- name: Check out release candidate
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
with:
ref: ${{ needs.guard.outputs.release_sha }}
persist-credentials: false
- name: Set up Node.js for portable regressions
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with:
node-version: '22'
- name: Check shell syntax
shell: bash
run: |
set -euo pipefail
while IFS= read -r file; do
bash -n "$file"
done < <(
find macos -type f \( -name '*.sh' -o -name '*.command' \) \
! -path '*/release/*' -print
)
- name: Check Node.js syntax on both platforms
shell: bash
run: |
set -euo pipefail
while IFS= read -r file; do
node --check "$file" >/dev/null
done < <(
find runtime tools macos windows -type f \( -name '*.mjs' -o -name '*.js' \) -print
)
# macos/tests/run-tests.sh already re-checks the macOS runtime assertions
# (legacy identifiers, app.asar mutation, python3/eval), so only the
# Windows execution-policy boundary is repeated here: the PowerShell
# suites assert it on named files, not across the whole tree.
- name: Check Windows execution-policy boundary
shell: bash
run: |
set -euo pipefail
if grep -R -n --include='*.ps1' --include='*.iss' \
-F -- '-ExecutionPolicy Bypass' windows/scripts windows/installer >/dev/null; then
printf 'Windows runtime or installer code bypasses the PowerShell execution policy.\n' >&2
exit 1
fi
- name: Check Windows PowerShell source encoding
shell: bash
run: |
node <<'NODE'
const fs = require("node:fs");
const path = require("node:path");
function visit(directory) {
for (const entry of fs.readdirSync(directory, { withFileTypes: true })) {
const file = path.join(directory, entry.name);
if (entry.isDirectory()) visit(file);
if (!entry.isFile() || path.extname(file) !== ".ps1") continue;
const bytes = fs.readFileSync(file);
const hasBom = bytes.subarray(0, 3).equals(Buffer.from([0xef, 0xbb, 0xbf]));
const content = hasBom ? bytes.subarray(3) : bytes;
if (content.some((byte) => byte >= 0x80) && !hasBom) {
throw new Error(`${file}: non-ASCII PowerShell 5.1 source requires a UTF-8 BOM`);
}
}
}
visit("windows");
NODE
# The platform runners execute run-tests.sh / run-tests.ps1, which only
# reference a subset of the *.test.mjs suites by name. These globs are
# the only thing that reaches every portable regression, so they must run
# against the release commit before its tag exists.
- name: Run portable Node.js regressions
shell: bash
run: |
set -euo pipefail
node --test macos/tests/*.test.mjs
node --test windows/tests/*.test.mjs
node --test tools/*.test.mjs
node macos/scripts/injector.mjs --check-payload >/dev/null
node windows/scripts/injector.mjs --check-payload >/dev/null
create-tag:
name: Create release tag
needs: [guard, regressions]
if: needs.guard.outputs.should_release == 'true'
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- name: Check out release candidate
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
with:
ref: ${{ needs.guard.outputs.release_sha }}
fetch-depth: 0
persist-credentials: false
- name: Create or verify tag
shell: bash
env:
GH_TOKEN: ${{ github.token }}
TAG: ${{ needs.guard.outputs.tag }}
RELEASE_SHA: ${{ needs.guard.outputs.release_sha }}
run: |
set -euo pipefail
existing="$(git ls-remote origin "refs/tags/$TAG" | awk 'NR == 1 { print $1 }')"
if [[ -n "$existing" ]]; then
[[ "$existing" == "$RELEASE_SHA" ]] || {
echo "Refusing to reuse $TAG at $existing; expected $RELEASE_SHA." >&2
exit 1
}
exit 0
fi
created="$(gh api --method POST "repos/$GITHUB_REPOSITORY/git/refs" \
-f "ref=refs/tags/$TAG" -f "sha=$RELEASE_SHA" --jq '.object.sha')"
[[ "$created" == "$RELEASE_SHA" ]] || {
echo "GitHub created $TAG at unexpected SHA: $created" >&2
exit 1
}
build-macos:
name: Build macOS DMG
needs: [guard, create-tag]
if: needs.guard.outputs.should_release == 'true'
runs-on: macos-latest
steps:
- name: Check out source
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
with:
ref: ${{ needs.guard.outputs.release_sha }}
persist-credentials: false
- name: Set up Node.js for macOS regressions
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with:
node-version: '22'
- name: Build unsigned DMG
shell: bash
env:
VERSION: ${{ needs.guard.outputs.version }}
run: |
set -euo pipefail
NODE="$(command -v node)" \
CODEX_DREAM_SKIN_SKIP_SIGNED_RUNTIME_TESTS=1 \
CODEX_DREAM_SKIN_SKIP_DOCTOR=1 \
./macos/tests/run-tests.sh
swift test --package-path macos/menubar-app
test -x macos/scripts/build-dmg.sh || chmod +x macos/scripts/build-dmg.sh
# The builder owns app assembly, engine staging, and ad-hoc signing.
# Release signing/notarization remains optional and is intentionally
# outside this unsigned public workflow.
./macos/scripts/build-dmg.sh --skip-tests
artifact="macos/release/CodexDreamSkin-v${VERSION}.dmg"
test -s "$artifact"
hdiutil imageinfo "$artifact" >/dev/null
- name: Upload macOS artifact
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
with:
name: release-macos
if-no-files-found: error
path: macos/release/CodexDreamSkin-v${{ needs.guard.outputs.version }}.dmg
retention-days: 14
build-windows:
name: Build Windows Setup
needs: [guard, create-tag]
if: needs.guard.outputs.should_release == 'true'
runs-on: windows-latest
steps:
- name: Check out source
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
with:
ref: ${{ needs.guard.outputs.release_sha }}
persist-credentials: false
- name: Set up Node.js for Windows regressions
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with:
node-version: '22'
- name: Install Inno Setup 6
shell: pwsh
run: |
$requiredVersion = '6.7.1'
choco upgrade innosetup --version=$requiredVersion --allow-downgrade --no-progress --yes
if ($LASTEXITCODE -ne 0) { throw "Could not install Inno Setup $requiredVersion." }
$resolved = @(
(Join-Path ${env:ProgramFiles(x86)} 'Inno Setup 6\ISCC.exe'),
(Join-Path $env:ProgramFiles 'Inno Setup 6\ISCC.exe'),
(Get-Command ISCC.exe -ErrorAction SilentlyContinue).Source
) | Where-Object { $_ -and (Test-Path -LiteralPath $_ -PathType Leaf) } | Select-Object -First 1
if (-not $resolved) { throw 'Inno Setup 6 (ISCC.exe) is not available on the runner.' }
"ISCC_PATH=$resolved" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append
- name: Build Setup.exe with pinned Node runtime
shell: pwsh
env:
VERSION: ${{ needs.guard.outputs.version }}
run: |
# Users run the shipped scripts under whichever shell their machine
# has, so both hosts must pass before the artifact is built. Only
# Windows PowerShell 5.1 was exercised here previously.
foreach ($shell in @('powershell.exe', 'pwsh.exe')) {
& $shell -NoLogo -NoProfile -ExecutionPolicy RemoteSigned `
-File .\windows\tests\run-tests.ps1
if ($LASTEXITCODE -ne 0) { throw "Windows regressions failed under $shell with exit code $LASTEXITCODE." }
& $shell -NoLogo -NoProfile -ExecutionPolicy RemoteSigned `
-File .\windows\tests\installer-static.tests.ps1
if ($LASTEXITCODE -ne 0) { throw "Installer static checks failed under $shell with exit code $LASTEXITCODE." }
}
$output = Join-Path $env:RUNNER_TEMP 'codex-dream-skin-release'
New-Item -ItemType Directory -Path $output -Force | Out-Null
& powershell.exe -NoProfile -ExecutionPolicy RemoteSigned `
-File .\windows\installer\build-release.ps1 `
-OutputDirectory $output -IsccPath $env:ISCC_PATH
if ($LASTEXITCODE -ne 0) { throw "Windows release builder failed with exit code $LASTEXITCODE." }
$artifact = Join-Path $output "CodexDreamSkin-Setup-v$env:VERSION.exe"
if (-not (Test-Path -LiteralPath $artifact -PathType Leaf)) {
throw "Expected Windows artifact was not created: $artifact"
}
if ((Get-Item -LiteralPath $artifact).Length -le 0) { throw 'Windows artifact is empty.' }
- name: Upload Windows artifact
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
with:
name: release-windows
if-no-files-found: error
path: ${{ runner.temp }}/codex-dream-skin-release/CodexDreamSkin-Setup-v${{ needs.guard.outputs.version }}.exe
retention-days: 14
publish-release:
name: Validate and publish GitHub Release
needs: [guard, create-tag, build-macos, build-windows]
if: needs.guard.outputs.should_release == 'true'
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- name: Check out source
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
with:
ref: ${{ needs.guard.outputs.release_sha }}
persist-credentials: false
- name: Download release artifacts
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
with:
pattern: release-*
path: dist
merge-multiple: true
- name: Generate checksums and release notes
shell: bash
env:
VERSION: ${{ needs.guard.outputs.version }}
RELEASE_SHA: ${{ needs.guard.outputs.release_sha }}
run: |
set -euo pipefail
test -s "dist/CodexDreamSkin-v${VERSION}.dmg"
test -s "dist/CodexDreamSkin-Setup-v${VERSION}.exe"
(
cd dist
sha256sum "CodexDreamSkin-v${VERSION}.dmg" \
"CodexDreamSkin-Setup-v${VERSION}.exe" > SHA256SUMS.txt
)
cat > release-notes.md <<EOF
## Codex Dream Skin v${VERSION}
### Downloads
- **macOS:** [CodexDreamSkin-v${VERSION}.dmg](https://github.com/Fei-Away/Codex-Dream-Skin/releases/download/v${VERSION}/CodexDreamSkin-v${VERSION}.dmg)
- **Windows:** [CodexDreamSkin-Setup-v${VERSION}.exe](https://github.com/Fei-Away/Codex-Dream-Skin/releases/download/v${VERSION}/CodexDreamSkin-Setup-v${VERSION}.exe)
- [SHA256SUMS.txt](https://github.com/Fei-Away/Codex-Dream-Skin/releases/download/v${VERSION}/SHA256SUMS.txt)
The packages are currently unsigned. Follow the graphical first-run steps in
[macOS installation](https://github.com/Fei-Away/Codex-Dream-Skin/blob/v${VERSION}/docs/install-macos.md)
or [Windows installation](https://github.com/Fei-Away/Codex-Dream-Skin/blob/v${VERSION}/docs/install-windows.md).
No terminal trust command is required for ordinary users.
This release was built automatically from ${RELEASE_SHA} after the version bump reached main.
EOF
- name: Stage, verify, and publish release
env:
GH_TOKEN: ${{ github.token }}
VERSION: ${{ needs.guard.outputs.version }}
TAG: ${{ needs.guard.outputs.tag }}
RELEASE_SHA: ${{ needs.guard.outputs.release_sha }}
run: |
set -euo pipefail
tag_sha="$(gh api "repos/$GITHUB_REPOSITORY/git/ref/tags/$TAG" --jq '.object.sha')"
[[ "$tag_sha" == "$RELEASE_SHA" ]] || {
echo "Release tag $TAG points at $tag_sha, expected $RELEASE_SHA." >&2
exit 1
}
if gh release view "$TAG" >/dev/null 2>&1; then
is_draft="$(gh release view "$TAG" --json isDraft --jq .isDraft)"
test "$is_draft" = true || {
echo "Refusing to replace an already published v${VERSION} release." >&2
exit 1
}
gh release upload "$TAG" --clobber \
dist/CodexDreamSkin-v${VERSION}.dmg \
dist/CodexDreamSkin-Setup-v${VERSION}.exe \
dist/SHA256SUMS.txt
gh release edit "$TAG" --draft \
--title "Codex Dream Skin v${VERSION}" \
--notes-file release-notes.md
else
gh release create "$TAG" \
--verify-tag \
--draft \
--title "Codex Dream Skin v${VERSION}" \
--notes-file release-notes.md \
dist/CodexDreamSkin-v${VERSION}.dmg \
dist/CodexDreamSkin-Setup-v${VERSION}.exe \
dist/SHA256SUMS.txt
fi
RELEASE_JSON="$(gh release view "$TAG" --json isDraft,isPrerelease,tagName,assets)"
RELEASE_JSON="$RELEASE_JSON" VERSION="$VERSION" node <<'NODE'
const fs = require("node:fs");
const crypto = require("node:crypto");
const release = JSON.parse(process.env.RELEASE_JSON);
if (!release.isDraft || release.isPrerelease || release.tagName !== "v" + process.env.VERSION) {
throw new Error("Draft release metadata is invalid before publication.");
}
const expected = [
"CodexDreamSkin-v" + process.env.VERSION + ".dmg",
"CodexDreamSkin-Setup-v" + process.env.VERSION + ".exe",
"SHA256SUMS.txt",
];
if (release.assets.length !== expected.length || !expected.every((name) => release.assets.some((asset) => asset.name === name))) {
throw new Error("Release asset set is incomplete or contains unexpected files.");
}
for (const name of expected) {
const asset = release.assets.find((item) => item.name === name);
const local = fs.readFileSync("dist/" + name);
const digest = "sha256:" + crypto.createHash("sha256").update(local).digest("hex");
if (!asset.size || asset.digest !== digest) throw new Error("Remote digest mismatch for " + name);
}
NODE
gh release edit "$TAG" --draft=false --latest
final_json="$(gh release view "$TAG" --json isDraft,isPrerelease,tagName,assets)"
RELEASE_JSON="$final_json" VERSION="$VERSION" node <<'NODE'
const release = JSON.parse(process.env.RELEASE_JSON);
const expected = [
"CodexDreamSkin-v" + process.env.VERSION + ".dmg",
"CodexDreamSkin-Setup-v" + process.env.VERSION + ".exe",
"SHA256SUMS.txt",
];
if (release.isDraft || release.isPrerelease || release.tagName !== "v" + process.env.VERSION ||
release.assets.length !== expected.length || !expected.every((name) => release.assets.some((asset) => asset.name === name && asset.state === "uploaded"))) {
throw new Error("Published release verification failed.");
}
console.log("Published public release " + release.tagName + " with " + release.assets.length + " verified assets.");
NODE