From 41afe7bc5b9737dd95b69093b723dfe8b5f4da86 Mon Sep 17 00:00:00 2001 From: HerbertGao Date: Mon, 26 Oct 2026 17:17:50 +0800 Subject: [PATCH 1/5] =?UTF-8?q?fix(ci):=20tag=20=E5=8F=91=E5=B8=83?= =?UTF-8?q?=E6=97=B6=E5=88=9B=E5=BB=BA=20GitHub=20Release=20=E5=B9=B6?= =?UTF-8?q?=E8=A7=A6=E5=8F=91=E7=8B=AC=E7=AB=8B=E7=89=88=E6=89=93=E5=8C=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit release-prepare.yml 仍只在 Fission-AI/OpenSpec 上运行,不会创建 Release。 GITHUB_TOKEN 创建的 Release 不会触发其他 workflow,因此显式 dispatch binaries.yml。 --- .github/workflows/release.yml | 30 +++++++++++++++++++++++++++++- 1 file changed, 29 insertions(+), 1 deletion(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 78554963d4..17673d04fe 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -34,4 +34,32 @@ jobs: - name: Publish to npm env: NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} - run: pnpm run release:ci \ No newline at end of file + run: pnpm run release:ci + + # This fork publishes by pushing a tag. release-prepare.yml still only runs + # when github.repository is Fission-AI/OpenSpec, so it never creates the + # GitHub Release that binaries.yml attaches standalone files to. + github-release: + name: GitHub Release + runs-on: ubuntu-latest + permissions: + contents: write + actions: write + steps: + - name: Create the release and build standalone binaries + env: + GH_TOKEN: ${{ github.token }} + GH_REPO: ${{ github.repository }} + run: | + set -eu + tag="${GITHUB_REF_NAME}" + if gh release view "$tag" >/dev/null 2>&1; then + echo "GitHub Release ${tag} already exists." + else + case "$tag" in + *-* ) gh release create "$tag" --title "$tag" --notes "Release ${tag}." --prerelease ;; + * ) gh release create "$tag" --title "$tag" --notes "Release ${tag}." ;; + esac + fi + # A release created with GITHUB_TOKEN does not start other workflows. + gh workflow run binaries.yml --ref main -f tag="$tag" \ No newline at end of file From a79b9640db98f80f256c2a773a3043a02b28051c Mon Sep 17 00:00:00 2001 From: HerbertGao Date: Mon, 26 Oct 2026 17:38:31 +0800 Subject: [PATCH 2/5] =?UTF-8?q?fix(ci):=20=E7=AD=89=E7=8B=AC=E7=AB=8B?= =?UTF-8?q?=E7=89=88=E6=96=87=E4=BB=B6=E4=B8=8A=E4=BC=A0=E5=AE=8C=E6=88=90?= =?UTF-8?q?=E5=86=8D=E8=AE=A9=E5=8F=91=E5=B8=83=E5=8F=98=E7=BB=BF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit workflow_dispatch 只是排队,发布 job 会在 SHA256SUMS 出现前成功。 改为调用 binaries.yml;已有完整 Release 则跳过。 --- .github/workflows/binaries.yml | 12 +++++++-- .github/workflows/release.yml | 47 +++++++++++++++++++++++++++++----- 2 files changed, 50 insertions(+), 9 deletions(-) diff --git a/.github/workflows/binaries.yml b/.github/workflows/binaries.yml index ab45273114..5525deeaa3 100644 --- a/.github/workflows/binaries.yml +++ b/.github/workflows/binaries.yml @@ -14,6 +14,12 @@ on: tag: description: Existing release tag to build and attach binaries to required: true + workflow_call: + inputs: + tag: + description: Existing release tag to build and attach binaries to + required: true + type: string permissions: contents: read @@ -53,8 +59,10 @@ jobs: - run: bun scripts/binary/build.mjs --all # The binaries report package.json's version (smoke.sh checks), so this # keeps assets off a release they do not belong to. + # A reusable call keeps the caller's event name, so a tag push arrives as + # push. inputs.tag is set only for that call and workflow_dispatch. - name: Check the version matches the release tag - if: github.event_name == 'release' || github.event_name == 'workflow_dispatch' + if: github.event_name == 'release' || github.event_name == 'workflow_dispatch' || (inputs.tag || '') != '' run: | version=$(node -p "require('./package.json').version") # Tags are v1.2.3, or @studyzy/openspec-cn@1.2.3 as changesets first @@ -253,7 +261,7 @@ jobs: release: name: Attach to release needs: verify - if: github.repository == 'studyzy/OpenSpec-cn' && (github.event_name == 'release' || github.event_name == 'workflow_dispatch') + if: github.repository == 'studyzy/OpenSpec-cn' && (github.event_name == 'release' || github.event_name == 'workflow_dispatch' || (inputs.tag || '') != '') runs-on: ubuntu-latest timeout-minutes: 10 permissions: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 17673d04fe..e190aaeafd 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -39,27 +39,60 @@ jobs: # This fork publishes by pushing a tag. release-prepare.yml still only runs # when github.repository is Fission-AI/OpenSpec, so it never creates the # GitHub Release that binaries.yml attaches standalone files to. + # A GITHUB_TOKEN release does not start other workflows. Calling binaries.yml + # keeps this run red until SHA256SUMS is actually on the release. github-release: name: GitHub Release runs-on: ubuntu-latest + outputs: + ready: ${{ steps.state.outputs.ready }} permissions: contents: write - actions: write steps: - - name: Create the release and build standalone binaries + - id: state + name: Create the GitHub Release env: GH_TOKEN: ${{ github.token }} GH_REPO: ${{ github.repository }} run: | set -eu tag="${GITHUB_REF_NAME}" - if gh release view "$tag" >/dev/null 2>&1; then - echo "GitHub Release ${tag} already exists." - else + if gh release view "$tag" --json assets --jq 'any(.assets[]; .name == "SHA256SUMS")' 2>/dev/null | grep -qx true; then + echo "ready=false" >> "$GITHUB_OUTPUT" + echo "Release ${tag} already has SHA256SUMS." + exit 0 + fi + if ! gh release view "$tag" >/dev/null 2>&1; then case "$tag" in *-* ) gh release create "$tag" --title "$tag" --notes "Release ${tag}." --prerelease ;; * ) gh release create "$tag" --title "$tag" --notes "Release ${tag}." ;; esac fi - # A release created with GITHUB_TOKEN does not start other workflows. - gh workflow run binaries.yml --ref main -f tag="$tag" \ No newline at end of file + echo "ready=true" >> "$GITHUB_OUTPUT" + + standalone: + name: Standalone binaries + needs: github-release + if: needs.github-release.outputs.ready == 'true' && github.repository == 'studyzy/OpenSpec-cn' + permissions: + contents: write + id-token: write + attestations: write + artifact-metadata: write + uses: ./.github/workflows/binaries.yml + with: + tag: ${{ github.ref_name }} + + confirm: + name: Require SHA256SUMS + needs: [github-release, standalone] + if: always() && needs.github-release.result == 'success' && needs.github-release.outputs.ready == 'true' && github.repository == 'studyzy/OpenSpec-cn' + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - name: Require SHA256SUMS on the release + env: + GH_TOKEN: ${{ github.token }} + GH_REPO: ${{ github.repository }} + run: gh release view "$GITHUB_REF_NAME" --json assets --jq 'any(.assets[]; .name == "SHA256SUMS")' | grep -qx true \ No newline at end of file From d678db71471e876371219d67a9940508a3c4b888 Mon Sep 17 00:00:00 2001 From: HerbertGao Date: Mon, 26 Oct 2026 18:14:02 +0800 Subject: [PATCH 3/5] =?UTF-8?q?fix(install):=20=E9=81=BF=E5=85=8D=20macOS?= =?UTF-8?q?=20bash=20=E5=92=8C=20PowerShell=20=E6=8A=8A=E5=8F=98=E9=87=8F?= =?UTF-8?q?=E5=90=8D=E8=AF=BB=E6=96=AD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 全角括号紧挨 $asset 时,bash 3.2 会把它算进变量名。 "$releases.atom" 在 PowerShell 里是取属性,不是拼 .atom。 --- scripts/binary/install.ps1 | 3 ++- scripts/binary/install.sh | 5 +++-- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/scripts/binary/install.ps1 b/scripts/binary/install.ps1 index 318a80de0b..a20f87a7d4 100644 --- a/scripts/binary/install.ps1 +++ b/scripts/binary/install.ps1 @@ -68,7 +68,8 @@ # "latest" is not trusted. SHA256SUMS is uploaded last: a release that # has it is complete. $feed = '' - try { $feed = (Invoke-WebRequest -UseBasicParsing -Uri "$releases.atom").Content } catch { Write-Verbose "No release feed: $_" } + # "$releases.atom" is a property lookup and is empty. + try { $feed = (Invoke-WebRequest -UseBasicParsing -Uri "${releases}.atom").Content } catch { Write-Verbose "No release feed: $_" } $tags = @([regex]::Matches($feed, '/releases/tag/(v\d+\.\d+\.\d+)"') | ForEach-Object { $_.Groups[1].Value } | Select-Object -Unique) foreach ($tag in $tags) { try { diff --git a/scripts/binary/install.sh b/scripts/binary/install.sh index 1319d69cc3..7117ca8d1e 100644 --- a/scripts/binary/install.sh +++ b/scripts/binary/install.sh @@ -138,8 +138,9 @@ main() { base="$releases/download/$version" fi - echo "正在下载 $asset($version)..." - download "$base/$asset" "$tmp/$asset" || fail "无法下载 $base/$asset;$npm_hint" + # Braces: macOS bash 3.2 folds the next UTF-8 byte into an unbraced name. + echo "正在下载 ${asset}(${version})..." + download "$base/$asset" "$tmp/$asset" || fail "无法下载 $base/${asset};$npm_hint" if [ ! -s "$tmp/SHA256SUMS" ]; then download "$base/SHA256SUMS" "$tmp/SHA256SUMS" || fail "无法下载 $base/SHA256SUMS;$npm_hint" fi From d09989e2299c686f235db7014f3814dceaace8ca Mon Sep 17 00:00:00 2001 From: HerbertGao Date: Mon, 26 Oct 2026 18:33:46 +0800 Subject: [PATCH 4/5] =?UTF-8?q?fix(install):=20=E6=8A=8A=20Windows=20?= =?UTF-8?q?=E5=8F=91=E5=B8=83=E5=88=97=E8=A1=A8=E4=BB=8E=E5=AD=97=E8=8A=82?= =?UTF-8?q?=E6=95=B0=E7=BB=84=E8=BD=AC=E6=88=90=E5=AD=97=E7=AC=A6=E4=B8=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PowerShell 7 对非文本类型的 atom 响应返回 byte[],正则匹配不到标签。 --- scripts/binary/install.ps1 | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/scripts/binary/install.ps1 b/scripts/binary/install.ps1 index a20f87a7d4..e4acf787fa 100644 --- a/scripts/binary/install.ps1 +++ b/scripts/binary/install.ps1 @@ -68,8 +68,11 @@ # "latest" is not trusted. SHA256SUMS is uploaded last: a release that # has it is complete. $feed = '' - # "$releases.atom" is a property lookup and is empty. - try { $feed = (Invoke-WebRequest -UseBasicParsing -Uri "${releases}.atom").Content } catch { Write-Verbose "No release feed: $_" } + # PowerShell 7 returns a byte array unless the feed is a text type. + try { + $raw = (Invoke-WebRequest -UseBasicParsing -Uri "$releases.atom").Content + $feed = if ($raw -is [byte[]]) { [Text.Encoding]::UTF8.GetString($raw) } else { [string]$raw } + } catch { Write-Verbose "No release feed: $_" } $tags = @([regex]::Matches($feed, '/releases/tag/(v\d+\.\d+\.\d+)"') | ForEach-Object { $_.Groups[1].Value } | Select-Object -Unique) foreach ($tag in $tags) { try { From 75004e12654b18f9a5e1b9a3e7ec61018e5064a7 Mon Sep 17 00:00:00 2001 From: HerbertGao Date: Mon, 26 Oct 2026 18:54:44 +0800 Subject: [PATCH 5/5] =?UTF-8?q?fix(ci):=20=E4=BF=AE=E6=AD=A3=20Windows=20?= =?UTF-8?q?=E5=9B=9E=E9=80=80=E5=AE=89=E8=A3=85=E5=92=8C=E6=97=A5=E5=BF=97?= =?UTF-8?q?=E4=B9=B1=E7=A0=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 本地发布列表按原始字节读取,避免 PowerShell 7 把 atom 当成字节数组。 测试改为按 UTF-8 读取安装脚本,并给 install.ps1 加上 BOM。 --- .github/workflows/binaries.yml | 42 +++++++++++++++++++++++++++------- scripts/binary/install.ps1 | 9 ++++---- 2 files changed, 39 insertions(+), 12 deletions(-) diff --git a/.github/workflows/binaries.yml b/.github/workflows/binaries.yml index 5525deeaa3..92cba7e615 100644 --- a/.github/workflows/binaries.yml +++ b/.github/workflows/binaries.yml @@ -166,6 +166,28 @@ jobs: shell: pwsh run: | Invoke-ScriptAnalyzer -Path scripts/binary/install.ps1 -Severity Warning,Error -ExcludeRule PSAvoidUsingWriteHost -EnableExit + # A failing installer is a native exit. Don't hide its output. + $PSNativeCommandUseErrorActionPreference = $false + # Python does not send charset=utf-8, so irm makes Windows PowerShell 5.1 + # decode the UTF-8 script as the ANSI code page. + function Invoke-Installer([string]$Shell, [string]$Dir, [string]$DownloadUrl, [string]$ReleasesUrl, [string]$Name) { + $p = Join-Path $env:RUNNER_TEMP ('i' + [guid]::NewGuid().ToString('n') + '.ps1') + $lines = @('$ErrorActionPreference = "Stop"') + if ($DownloadUrl) { $lines += "`$env:OPENSPEC_DOWNLOAD_URL = '$DownloadUrl'" } + if ($ReleasesUrl) { $lines += "`$env:OPENSPEC_RELEASES_URL = '$ReleasesUrl'" } + if ($Name) { $lines += "`$env:OPENSPEC_INSTALL_NAME = '$Name'" } + $lines += "`$env:OPENSPEC_INSTALL_DIR = '$Dir'" + $lines += @' +$dest = Join-Path $env:TEMP "openspec-cn-install.ps1" +Invoke-WebRequest -UseBasicParsing http://127.0.0.1:8765/install.ps1 -OutFile $dest +$text = [Text.Encoding]::UTF8.GetString([IO.File]::ReadAllBytes($dest)) +if ($text.StartsWith([char]0xFEFF)) { $text = $text.Substring(1) } +Invoke-Expression $text +'@ + Set-Content -Path $p -Value ($lines -join "`n") -Encoding ascii + & $Shell -NoProfile -File $p + $script:installerExit = $LASTEXITCODE + } $server = Start-Process python -ArgumentList '-m', 'http.server', '8765', '--directory', 'dist-bin' -PassThru -WindowStyle Hidden foreach ($i in 1..40) { try { Invoke-WebRequest -UseBasicParsing http://127.0.0.1:8765/SHA256SUMS | Out-Null; break } catch { Start-Sleep -Milliseconds 500 } } # A user PATH entry that relies on expansion must survive untouched. @@ -176,8 +198,8 @@ jobs: foreach ($shell in 'pwsh', 'powershell') { $dir = Join-Path $env:RUNNER_TEMP "install $shell" foreach ($run in 1, 2) { - & $shell -NoProfile -Command "`$env:OPENSPEC_DOWNLOAD_URL='http://127.0.0.1:8765'; `$env:OPENSPEC_INSTALL_DIR='$dir'; irm http://127.0.0.1:8765/install.ps1 | iex" - if ($LASTEXITCODE -ne 0) { throw "$shell install run $run failed" } + Invoke-Installer $shell $dir 'http://127.0.0.1:8765' $null $null + if ($script:installerExit -ne 0) { throw "$shell install run $run failed" } } $exe = Join-Path $dir 'openspec-cn.exe' if ((Get-FileHash $exe).Hash -ne (Get-FileHash "dist-bin/openspec-cn-$env:ASSET.exe").Hash) { throw "$shell installed the wrong file" } @@ -190,9 +212,9 @@ jobs: } # A relative folder is saved to PATH as an absolute one; a custom name is kept. Push-Location $env:RUNNER_TEMP - pwsh -NoProfile -Command "`$env:OPENSPEC_DOWNLOAD_URL='http://127.0.0.1:8765'; `$env:OPENSPEC_INSTALL_DIR='relative install'; `$env:OPENSPEC_INSTALL_NAME='openspec-cn-renamed.exe'; irm http://127.0.0.1:8765/install.ps1 | iex" + Invoke-Installer pwsh (Join-Path $env:RUNNER_TEMP 'relative install') 'http://127.0.0.1:8765' $null 'openspec-cn-renamed.exe' Pop-Location - if ($LASTEXITCODE -ne 0) { throw 'relative install failed' } + if ($script:installerExit -ne 0) { throw 'relative install failed' } $abs = Join-Path $env:RUNNER_TEMP 'relative install' if (-not (Test-Path (Join-Path $abs 'openspec-cn-renamed.exe'))) { throw 'custom name not used' } $raw = [string]$key.GetValue('Path', '', 'DoNotExpandEnvironmentNames') @@ -210,13 +232,17 @@ jobs: Set-Content -Path (Join-Path $site 'releases.atom') -Value ('' + (('v1.2.0', 'v1.1.0' | ForEach-Object { "" }) -join '') + '') foreach ($shell in 'pwsh', 'powershell') { $dir = Join-Path $env:RUNNER_TEMP "fallback $shell" - $out = & $shell -NoProfile -Command "`$env:OPENSPEC_RELEASES_URL='http://127.0.0.1:8765/site/releases'; `$env:OPENSPEC_INSTALL_DIR='$dir'; irm http://127.0.0.1:8765/install.ps1 | iex" 2>&1 | Out-String - if ($LASTEXITCODE -ne 0) { throw "$shell fallback install failed: $out" } + $log = Join-Path $env:RUNNER_TEMP "fallback-$shell.txt" + Invoke-Installer $shell $dir $null 'http://127.0.0.1:8765/site/releases' $null *> $log + $out = if (Test-Path $log) { Get-Content -Raw $log } else { '' } + if ($script:installerExit -ne 0) { throw "$shell fallback install failed: $out" } if ($out -notmatch 'v1\.2\.0 is still being prepared; installing v1\.1\.0\.') { throw "$shell did not fall back with a note: $out" } if ((Get-FileHash (Join-Path $dir 'openspec-cn.exe')).Hash -ne (Get-FileHash "dist-bin/openspec-cn-$env:ASSET.exe").Hash) { throw "$shell fallback installed the wrong file" } # A localhost-looking URL whose real host is remote is refused before any download. - $out = & $shell -NoProfile -Command "`$env:OPENSPEC_DOWNLOAD_URL='http://localhost:80@evil.example/x'; `$env:OPENSPEC_INSTALL_DIR='$dir-evil'; irm http://127.0.0.1:8765/install.ps1 | iex" 2>&1 | Out-String - if ($LASTEXITCODE -eq 0 -or $out -notmatch 'must use https') { throw "$shell accepted a remote host behind localhost: $out" } + $log = Join-Path $env:RUNNER_TEMP "evil-$shell.txt" + Invoke-Installer $shell "$dir-evil" 'http://localhost:80@evil.example/x' $null $null *> $log + $out = if (Test-Path $log) { Get-Content -Raw $log } else { '' } + if ($script:installerExit -eq 0 -or $out -notmatch 'must use https') { throw "$shell accepted a remote host behind localhost: $out" } if (Test-Path "$dir-evil/openspec-cn.exe") { throw "$shell installed from a refused source" } } } finally { diff --git a/scripts/binary/install.ps1 b/scripts/binary/install.ps1 index e4acf787fa..1a70d5dbba 100644 --- a/scripts/binary/install.ps1 +++ b/scripts/binary/install.ps1 @@ -1,4 +1,4 @@ -# Installs the standalone `openspec-cn.exe` (no Node.js needed) on Windows. +# Installs the standalone `openspec-cn.exe` (no Node.js needed) on Windows. # # irm https://raw.githubusercontent.com/studyzy/OpenSpec-cn/main/scripts/binary/install.ps1 | iex # @@ -68,10 +68,11 @@ # "latest" is not trusted. SHA256SUMS is uploaded last: a release that # has it is complete. $feed = '' - # PowerShell 7 returns a byte array unless the feed is a text type. + # .Content is a byte array for this feed, so the regex would see no tags. try { - $raw = (Invoke-WebRequest -UseBasicParsing -Uri "$releases.atom").Content - $feed = if ($raw -is [byte[]]) { [Text.Encoding]::UTF8.GetString($raw) } else { [string]$raw } + $feedPath = Join-Path $tmp 'releases.atom' + Invoke-WebRequest -UseBasicParsing -Uri "$releases.atom" -OutFile $feedPath + $feed = [IO.File]::ReadAllText($feedPath) } catch { Write-Verbose "No release feed: $_" } $tags = @([regex]::Matches($feed, '/releases/tag/(v\d+\.\d+\.\d+)"') | ForEach-Object { $_.Groups[1].Value } | Select-Object -Unique) foreach ($tag in $tags) {