11import { describe , expect , test } from "bun:test"
2+ import { execFileSync , spawnSync } from "node:child_process"
3+ import fs from "node:fs"
24import path from "node:path"
5+ import { tmpdir } from "../fixture/fixture"
36import { parseWorkflow , readWorkflow } from "./workflow-parser"
47
58const repoRoot = path . join ( import . meta. dir , "../../../.." )
69const workflowPath = path . join ( repoRoot , ".github" , "workflows" , "build.yml" )
710
811describe ( "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