Skip to content

Commit 18a2daa

Browse files
committed
ci: support Intel and Windows release targets
1 parent 6947d7b commit 18a2daa

3 files changed

Lines changed: 256 additions & 24 deletions

File tree

.github/workflows/build.yml

Lines changed: 122 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -21,12 +21,21 @@ on:
2121
- beta
2222
- prod
2323
default: dev
24+
target:
25+
description: "Release target"
26+
required: false
27+
type: choice
28+
options:
29+
- macos
30+
- windows
31+
default: macos
2432
arch:
2533
description: "Target architecture"
2634
required: false
2735
type: choice
2836
options:
2937
- arm64
38+
- x64
3039
default: arm64
3140
source_run_id:
3241
description: "Run ID from the submit phase"
@@ -58,16 +67,91 @@ on:
5867
type: string
5968

6069
concurrency:
61-
group: ${{ github.workflow }}-${{ github.ref }}-${{ inputs.phase || 'submit' }}
70+
group: ${{ github.workflow }}-${{ github.ref }}-${{ inputs.phase || 'submit' }}-${{ inputs.target || 'macos' }}-${{ inputs.arch || 'arm64' }}
6271
cancel-in-progress: true
6372

6473
permissions:
6574
actions: read
6675
contents: write
6776

6877
jobs:
78+
select-build-target:
79+
runs-on: ubuntu-latest
80+
outputs:
81+
matrix: ${{ steps.select.outputs.matrix }}
82+
target: ${{ steps.select.outputs.target }}
83+
arch: ${{ steps.select.outputs.arch }}
84+
steps:
85+
- id: select
86+
run: |
87+
set -euo pipefail
88+
89+
target="${INPUT_TARGET:-macos}"
90+
arch="${INPUT_ARCH:-arm64}"
91+
phase="${INPUT_PHASE:-submit}"
92+
93+
case "$target" in
94+
macos)
95+
case "$arch" in
96+
arm64)
97+
host="macos-latest"
98+
platform_flag="--mac --arm64"
99+
;;
100+
x64)
101+
host="macos-latest"
102+
platform_flag="--mac --x64"
103+
;;
104+
*)
105+
echo "Unsupported macOS arch: $arch"
106+
exit 1
107+
;;
108+
esac
109+
;;
110+
windows)
111+
if [ "$phase" = "finalize" ]; then
112+
echo "Windows releases do not use the macOS notarization finalize phase"
113+
exit 1
114+
fi
115+
if [ "$arch" != "x64" ]; then
116+
echo "Unsupported Windows arch: $arch"
117+
exit 1
118+
fi
119+
host="windows-latest"
120+
platform_flag="--win"
121+
;;
122+
*)
123+
echo "Unsupported release target: $target"
124+
exit 1
125+
;;
126+
esac
127+
128+
matrix=$(python3 - <<PY
129+
import json
130+
print(json.dumps({
131+
"include": [{
132+
"host": "$host",
133+
"target": "$target",
134+
"platform_flag": "$platform_flag",
135+
"arch_label": "$arch",
136+
}]
137+
}))
138+
PY
139+
)
140+
141+
{
142+
echo "target=$target"
143+
echo "arch=$arch"
144+
echo "matrix=$matrix"
145+
} >> "$GITHUB_OUTPUT"
146+
env:
147+
INPUT_TARGET: ${{ inputs.target || 'macos' }}
148+
INPUT_ARCH: ${{ inputs.arch || 'arm64' }}
149+
INPUT_PHASE: ${{ inputs.phase || 'submit' }}
150+
69151
create-snapshot-tag:
70-
if: ${{ inputs.phase == 'submit' }}
152+
needs:
153+
- select-build-target
154+
if: ${{ inputs.phase == 'submit' && needs.select-build-target.outputs.target == 'macos' }}
71155
runs-on: ubuntu-latest
72156
permissions:
73157
contents: write
@@ -79,7 +163,7 @@ jobs:
79163
run: |
80164
set -euo pipefail
81165
82-
workflow_ref="workflow-snapshot-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}-${{ inputs.arch || 'arm64' }}"
166+
workflow_ref="workflow-snapshot-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}-${{ needs.select-build-target.outputs.target }}-${{ needs.select-build-target.outputs.arch }}"
83167
gh api \
84168
--method POST \
85169
-H "Accept: application/vnd.github+json" \
@@ -93,38 +177,32 @@ jobs:
93177

94178
build-electron:
95179
needs:
180+
- select-build-target
96181
- create-snapshot-tag
97-
if: ${{ always() && (needs.create-snapshot-tag.result == 'success' || needs.create-snapshot-tag.result == 'skipped') }}
182+
if: ${{ always() && needs.select-build-target.result == 'success' && (needs.create-snapshot-tag.result == 'success' || needs.create-snapshot-tag.result == 'skipped') }}
98183
strategy:
99184
fail-fast: false
100-
matrix:
101-
include:
102-
- host: macos-latest
103-
platform_flag: --mac --arm64
104-
arch_label: arm64
105-
# Uncomment after signing is verified:
106-
# - host: macos-latest
107-
# platform_flag: --mac --x64
108-
# arch_label: x64
109-
# - host: ubuntu-latest
110-
# platform_flag: --linux
111-
# arch_label: x64
112-
# - host: windows-latest
113-
# platform_flag: --win
114-
# arch_label: x64
185+
matrix: ${{ fromJSON(needs.select-build-target.outputs.matrix) }}
115186

116187
runs-on: ${{ matrix.host }}
117188

118189
steps:
119-
- name: Validate Selected Arch
190+
- name: Validate selected target
191+
shell: bash
120192
run: |
121193
set -euo pipefail
122194
123-
if [ "$SELECTED_ARCH" != "arm64" ]; then
124-
echo "Unsupported arch: $SELECTED_ARCH"
195+
if [ "$SELECTED_TARGET" != "${{ matrix.target }}" ]; then
196+
echo "Target mismatch: expected $SELECTED_TARGET, got ${{ matrix.target }}"
197+
exit 1
198+
fi
199+
200+
if [ "$SELECTED_ARCH" != "${{ matrix.arch_label }}" ]; then
201+
echo "Arch mismatch: expected $SELECTED_ARCH, got ${{ matrix.arch_label }}"
125202
exit 1
126203
fi
127204
env:
205+
SELECTED_TARGET: ${{ needs.select-build-target.outputs.target }}
128206
SELECTED_ARCH: ${{ inputs.arch || 'arm64' }}
129207

130208
- name: Validate finalize inputs
@@ -639,11 +717,32 @@ jobs:
639717
640718
- name: Package app
641719
if: ${{ runner.os != 'macOS' && inputs.phase != 'finalize' }}
642-
run: npx electron-builder ${{ matrix.platform_flag }} --publish never --config electron-builder.config.ts
720+
shell: bash
721+
run: |
722+
set -euo pipefail
723+
724+
publish_flag="never"
725+
if [ "${{ inputs.phase || 'submit' }}" = "full" ]; then
726+
publish_flag="always"
727+
fi
728+
729+
npx electron-builder ${{ matrix.platform_flag }} --publish "$publish_flag" --config electron-builder.config.ts
643730
working-directory: packages/desktop-electron
644731
timeout-minutes: 60
645732
env:
646733
OPENCODE_CHANNEL: ${{ inputs.channel || 'dev' }}
734+
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
735+
736+
- name: Upload packaged app artifact
737+
if: ${{ runner.os != 'macOS' && inputs.phase != 'finalize' }}
738+
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a
739+
with:
740+
name: pawwork-${{ runner.os }}-${{ matrix.arch_label }}
741+
if-no-files-found: error
742+
path: |
743+
packages/desktop-electron/dist/*.exe
744+
packages/desktop-electron/dist/*.exe.blockmap
745+
packages/desktop-electron/dist/latest*.yml
647746
648747
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a
649748
if: ${{ runner.os == 'macOS' && (inputs.phase == 'finalize' || inputs.phase == 'full') && inputs.arch == matrix.arch_label }}

packages/opencode/test/github/build-workflow.test.ts

Lines changed: 124 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,58 @@
11
import { describe, expect, test } from "bun:test"
2+
import { execFileSync, spawnSync } from "node:child_process"
3+
import fs from "node:fs"
24
import path from "node:path"
5+
import { tmpdir } from "../fixture/fixture"
36
import { parseWorkflow, readWorkflow } from "./workflow-parser"
47

58
const repoRoot = path.join(import.meta.dir, "../../../..")
69
const workflowPath = path.join(repoRoot, ".github", "workflows", "build.yml")
710

811
describe("release workflow", () => {
12+
test("selects the macOS x64 release matrix", async () => {
13+
const result = await runSelectBuildTarget({ target: "macos", arch: "x64", phase: "submit" })
14+
15+
expect(result.status).toBe(0)
16+
expect(result.outputs.target).toBe("macos")
17+
expect(result.outputs.arch).toBe("x64")
18+
expect(JSON.parse(result.outputs.matrix ?? "")).toEqual({
19+
include: [{ host: "macos-latest", target: "macos", platform_flag: "--mac --x64", arch_label: "x64" }],
20+
})
21+
})
22+
23+
test("selects the Windows x64 release matrix", async () => {
24+
const result = await runSelectBuildTarget({ target: "windows", arch: "x64", phase: "submit" })
25+
26+
expect(result.status).toBe(0)
27+
expect(result.outputs.target).toBe("windows")
28+
expect(result.outputs.arch).toBe("x64")
29+
expect(JSON.parse(result.outputs.matrix ?? "")).toEqual({
30+
include: [{ host: "windows-latest", target: "windows", platform_flag: "--win", arch_label: "x64" }],
31+
})
32+
})
33+
34+
test("rejects unsupported Windows release combinations", async () => {
35+
const arm64 = await runSelectBuildTarget({ target: "windows", arch: "arm64", phase: "submit" })
36+
const finalize = await runSelectBuildTarget({ target: "windows", arch: "x64", phase: "finalize" })
37+
38+
expect(arm64.status).toBe(1)
39+
expect(arm64.output).toContain("Unsupported Windows arch: arm64")
40+
expect(finalize.status).toBe(1)
41+
expect(finalize.output).toContain("Windows releases do not use the macOS notarization finalize phase")
42+
})
43+
44+
test("ignores malformed GitHub output lines", () => {
45+
expect(parseGithubOutput("target=windows\nnot-an-output-line\narch=x64\n")).toEqual({
46+
target: "windows",
47+
arch: "x64",
48+
})
49+
})
50+
951
test("validates the release workflow configuration", () => {
1052
const workflow = readWorkflow(workflowPath)
1153
const parsed = parseWorkflow(workflowPath)
54+
const selectBuildTarget = parsed.jobs?.["select-build-target"]
55+
const createSnapshotTag = parsed.jobs?.["create-snapshot-tag"]
1256
const buildElectron = parsed.jobs?.["build-electron"]
1357
const cleanupSnapshotTag = parsed.jobs?.["cleanup-snapshot-tag"]
1458
const steps = buildElectron?.steps ?? []
@@ -22,14 +66,33 @@ describe("release workflow", () => {
2266
(step) => step.uses === "actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a",
2367
)
2468
const signedArtifactStep = steps.find((step) => step.name === "Upload signed app artifact")
69+
const nonMacArtifactStep = steps.find((step) => step.name === "Upload packaged app artifact")
70+
const packageAppStep = steps.find((step) => step.name === "Package app")
71+
const validateSelectedTargetStep = steps.find((step) => step.name === "Validate selected target")
2572

2673
expect(parsed.name).toBe("release")
2774
expect(parsed.permissions).toEqual({
2875
actions: "read",
2976
contents: "write",
3077
})
78+
expect(parsed.concurrency?.group).toBe(
79+
"${{ github.workflow }}-${{ github.ref }}-${{ inputs.phase || 'submit' }}-${{ inputs.target || 'macos' }}-${{ inputs.arch || 'arm64' }}",
80+
)
3181
expect(parsed.on?.workflow_dispatch).toBeDefined()
82+
expect(workflow).toContain("target:")
83+
expect(workflow).toContain("- macos")
84+
expect(workflow).toContain("- windows")
85+
expect(workflow).toContain("- x64")
86+
expect(selectBuildTarget?.["runs-on"]).toBe("ubuntu-latest")
87+
expect(selectBuildTarget?.outputs).toEqual({
88+
arch: "${{ steps.select.outputs.arch }}",
89+
matrix: "${{ steps.select.outputs.matrix }}",
90+
target: "${{ steps.select.outputs.target }}",
91+
})
92+
expect(createSnapshotTag?.needs).toContain("select-build-target")
3293
expect(buildElectron?.["runs-on"]).toBe("${{ matrix.host }}")
94+
expect(buildElectron?.needs).toEqual(["select-build-target", "create-snapshot-tag"])
95+
expect(buildElectron?.if).toContain("needs.select-build-target.result == 'success'")
3396
expect(cleanupSnapshotTag?.needs).toContain("build-electron")
3497
expect(cleanupSnapshotTag?.if).toBe(
3598
"${{ always() && inputs.phase == 'finalize' && needs.build-electron.result == 'success' }}",
@@ -41,10 +104,70 @@ describe("release workflow", () => {
41104
ref: "${{ inputs.source_sha }}",
42105
})
43106
expect(setupNodeStep?.with).toEqual({ "node-version": "24" })
44-
expect(uploadArtifactSteps).toHaveLength(2)
107+
expect(uploadArtifactSteps).toHaveLength(3)
45108
expect(signedArtifactStep?.uses).toBe("actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a")
109+
expect(nonMacArtifactStep?.if).toBe("${{ runner.os != 'macOS' && inputs.phase != 'finalize' }}")
110+
expect(nonMacArtifactStep?.with?.["if-no-files-found"]).toBe("error")
111+
expect(nonMacArtifactStep?.with?.path).toContain("packages/desktop-electron/dist/*.exe")
112+
expect(nonMacArtifactStep?.with?.path).toContain("packages/desktop-electron/dist/latest*.yml")
113+
expect(validateSelectedTargetStep?.shell).toBe("bash")
114+
expect(packageAppStep?.shell).toBe("bash")
115+
expect(packageAppStep?.env).toEqual({
116+
OPENCODE_CHANNEL: "${{ inputs.channel || 'dev' }}",
117+
GH_TOKEN: "${{ secrets.GITHUB_TOKEN }}",
118+
})
119+
expect(packageAppStep?.run).toContain('publish_flag="never"')
120+
expect(packageAppStep?.run).toContain('if [ "${{ inputs.phase || \'submit\' }}" = "full" ]; then')
121+
expect(packageAppStep?.run).toContain('publish_flag="always"')
46122

47123
expect(workflow).not.toContain("persist-credentials: true")
48124
expect(workflow).not.toContain("pull_request_target")
49125
})
50126
})
127+
128+
/** Runs the workflow selector step and returns the status plus GITHUB_OUTPUT entries. */
129+
async function runSelectBuildTarget(input: { target: string; arch: string; phase: string }) {
130+
const ruby = String.raw`
131+
require "yaml"
132+
133+
data = YAML.load_file(ARGV[0])
134+
step = data["jobs"]["select-build-target"]["steps"].find { |entry| entry["id"] == "select" }
135+
raise "Missing select-build-target step with id=select" unless step
136+
puts step["run"]
137+
`
138+
const script = execFileSync("ruby", ["-e", ruby, workflowPath], { encoding: "utf8" })
139+
await using tmp = await tmpdir()
140+
const outputPath = path.join(tmp.path, "github-output")
141+
const result = spawnSync("bash", ["-ec", script], {
142+
encoding: "utf8",
143+
env: {
144+
...process.env,
145+
INPUT_TARGET: input.target,
146+
INPUT_ARCH: input.arch,
147+
INPUT_PHASE: input.phase,
148+
GITHUB_OUTPUT: outputPath,
149+
},
150+
})
151+
152+
const outputs = fs.existsSync(outputPath) ? parseGithubOutput(fs.readFileSync(outputPath, "utf8")) : {}
153+
154+
return {
155+
status: result.status ?? 1,
156+
output: `${result.stdout}${result.stderr}`,
157+
outputs,
158+
}
159+
}
160+
161+
/** Parses the simple key=value records emitted to GITHUB_OUTPUT by this workflow step. */
162+
function parseGithubOutput(output: string) {
163+
return Object.fromEntries(
164+
output
165+
.trim()
166+
.split("\n")
167+
.filter(Boolean)
168+
.flatMap((line) => {
169+
const index = line.indexOf("=")
170+
return index === -1 ? [] : ([[line.slice(0, index), line.slice(index + 1)]] as const)
171+
}),
172+
)
173+
}

0 commit comments

Comments
 (0)