name: Niubash Release on: push: tags: - "v*" workflow_dispatch: inputs: tag: description: "Niubash tag to release, for example v0.7.1" required: true type: string winuxcmd_tag: description: "WinuxCmd tag to bundle, for example v1.1.7 (required, no 'latest' fallback — niubash#312)" required: true type: string winuxcmd_sha256: description: "Expected SHA-256 of the WinuxCmd zip per arch, JSON: {\"windows-x64\":\"…\",\"windows-arm64\":\"…\"} (niubash#312)" required: true type: string permissions: contents: write env: CARGO_TERM_COLOR: always RUST_TOOLCHAIN: stable jobs: build-windows: name: build-windows (${{ matrix.arch }}) runs-on: windows-2025 outputs: # Both matrix legs fetch the same PINNED rubash revision, so this # resolves to the same value in both; the pin steps fail the build if # the checkout is not exactly the pinned revision (rubash#827). rubash_revision: ${{ steps.rubash.outputs.revision }} strategy: fail-fast: false matrix: include: - arch: x64 rust_target: x86_64-pc-windows-msvc winuxcmd_asset_pattern: "WinuxCmd-*-win-x64.zip" - arch: arm64 rust_target: aarch64-pc-windows-msvc winuxcmd_asset_pattern: "WinuxCmd-*-win-arm64.zip" steps: - uses: actions/checkout@v4.2.2 with: fetch-depth: 0 # rubash#827 (conclusion B): the engine source is PINNED, never # "whatever rubash master HEAD was at clone time" -- that float is how # v1.4.0 (run 38506862578) compiled e785c3c8f610 while the release era # declared rubash v1.4.0 (f69e7900). The pin # (scripts/release/rubash-pin.json, the source twin of winuxcmd-pin.json # from niubash#312) names a full 40-char commit; this step fetches # exactly it and dies unless the checked-out HEAD equals the pin. The # clone lands at ../rubash, where the [patch] path override in # Cargo.toml deterministically takes over -- the tree Cargo compiles IS # the pinned tree. Read from the pre-tag-checkout tree: a tag push uses # the pin committed at the tag, a dispatch of an old tag runs today's # pin (same #312 semantics as the WinuxCmd pin). Bumping the pin is one # commit to that file; see its notes for the flow. - name: Checkout pinned rubash revision id: rubash-pin shell: pwsh run: | $ErrorActionPreference = "Stop" $pin = Get-Content -LiteralPath scripts/release/rubash-pin.json -Raw | ConvertFrom-Json $rev = $pin.rubash_rev if ($rev -notmatch '^[0-9a-f]{40}$') { throw "rubash-pin.json: rubash_rev must be a full 40-char commit sha, got '$rev' (abbreviated forms cannot be fetched -- git resolves the fetch argument server-side as a ref name)" } if (Test-Path -LiteralPath ..\rubash) { throw "a rubash checkout already exists at ..\rubash; refusing to pin over it" } git init ..\rubash if ($LASTEXITCODE -ne 0) { throw "git init ../rubash failed" } git -C ..\rubash remote add origin https://github.com/unixwin/rubash.git git -C ..\rubash fetch --depth 1 origin $rev if ($LASTEXITCODE -ne 0) { throw "could not fetch the pinned rubash revision $rev; if rubash master was rewritten upstream, bump scripts/release/rubash-pin.json (rubash#827)" } git -C ..\rubash checkout --quiet FETCH_HEAD if ($LASTEXITCODE -ne 0) { throw "could not check out the pinned rubash revision $rev" } git -C ..\rubash submodule update --init --depth 1 if ($LASTEXITCODE -ne 0) { throw "rubash submodules failed to initialize at the pinned revision" } $actual = (git -C ..\rubash rev-parse HEAD).Trim() if ($actual -ne $rev) { throw "rubash checkout resolved to $actual, not the pinned $rev; refusing to build (rubash#827)" } Write-Host "rubash pinned at $($rev.Substring(0,12)) (v$($pin.rubash_version))" "revision=$($rev.Substring(0,12))" >> $env:GITHUB_OUTPUT - name: Resolve release inputs id: release shell: pwsh run: | if ("${{ github.event_name }}" -eq "workflow_dispatch") { $tag = "${{ inputs.tag }}" } else { $tag = "${{ github.ref_name }}" } if ($tag -notmatch '^v\d+\.\d+\.\d+$') { throw "Invalid release tag '$tag'" } $version = $tag.Substring(1) "tag=$tag" >> $env:GITHUB_OUTPUT "version=$version" >> $env:GITHUB_OUTPUT - name: Preserve current release packager shell: pwsh run: | Copy-Item -LiteralPath scripts/package-release.ps1 -Destination (Join-Path $env:RUNNER_TEMP "package-release.ps1") -Force New-Item -ItemType Directory -Force -Path (Join-Path $env:RUNNER_TEMP "installer") | Out-Null Copy-Item -LiteralPath installer/niubash.iss -Destination (Join-Path $env:RUNNER_TEMP "installer\niubash.iss") -Force Copy-Item -LiteralPath assets/niubash-icon.ico -Destination (Join-Path $env:RUNNER_TEMP "niubash-icon.ico") -Force # niubash#312: the ecosystem pins are CURRENT release policy, same # discipline as the packager and the journey/perf gates — a dispatch # of an old tag runs today's pins (the tag's own copy predates them), # while a tag push re-reads the identical file committed at the tag. New-Item -ItemType Directory -Force -Path (Join-Path $env:RUNNER_TEMP "release-policy") | Out-Null Copy-Item -LiteralPath scripts/release/preinstall.json -Destination (Join-Path $env:RUNNER_TEMP "release-policy\preinstall.json") -Force Copy-Item -LiteralPath scripts/release/winuxcmd-pin.json -Destination (Join-Path $env:RUNNER_TEMP "release-policy\winuxcmd-pin.json") -Force Copy-Item -LiteralPath scripts/release/rubash-pin.json -Destination (Join-Path $env:RUNNER_TEMP "release-policy\rubash-pin.json") -Force - name: Checkout release tag shell: pwsh run: | git fetch --tags --force git checkout "${{ steps.release.outputs.tag }}" Copy-Item -LiteralPath (Join-Path $env:RUNNER_TEMP "package-release.ps1") -Destination scripts/package-release.ps1 -Force New-Item -ItemType Directory -Force -Path installer | Out-Null Copy-Item -LiteralPath (Join-Path $env:RUNNER_TEMP "installer\niubash.iss") -Destination installer/niubash.iss -Force Copy-Item -LiteralPath (Join-Path $env:RUNNER_TEMP "niubash-icon.ico") -Destination assets/niubash-icon.ico -Force New-Item -ItemType Directory -Force -Path scripts/release | Out-Null Copy-Item -LiteralPath (Join-Path $env:RUNNER_TEMP "release-policy\preinstall.json") -Destination scripts/release/preinstall.json -Force Copy-Item -LiteralPath (Join-Path $env:RUNNER_TEMP "release-policy\winuxcmd-pin.json") -Destination scripts/release/winuxcmd-pin.json -Force Copy-Item -LiteralPath (Join-Path $env:RUNNER_TEMP "release-policy\rubash-pin.json") -Destination scripts/release/rubash-pin.json -Force - name: Validate Cargo version matches release tag shell: pwsh run: | $cargoToml = Get-Content -LiteralPath Cargo.toml -Raw if ($cargoToml -notmatch '(?m)^version\s*=\s*"([^"]+)"') { throw "Could not read package version from Cargo.toml" } if ($Matches[1] -ne "${{ steps.release.outputs.version }}") { throw "Cargo.toml version $($Matches[1]) does not match release tag ${{ steps.release.outputs.tag }}" } - name: Install Rust toolchain uses: actions-rust-lang/setup-rust-toolchain@v1.11 with: toolchain: ${{ env.RUST_TOOLCHAIN }} - name: Install Rust target run: rustup target add ${{ matrix.rust_target }} # rubash is a path dependency, so `cargo update -p rubash` cannot pin it: # there is no registry entry to update, and the step silently did nothing. # The pinned-clone step above already fetched exactly the pinned commit; # this step re-derives the revision, refuses a dirty tree, and # cross-checks the pin before anything is compiled (rubash#827). The # revision is embedded at build time by build.rs, so `niu --version` # reports it. - name: Pin rubash revision id: rubash shell: pwsh run: | if (-not (Test-Path -LiteralPath ..\rubash)) { throw "rubash checkout missing at ..\rubash" } $revision = (git -C ..\rubash rev-parse --short=12 HEAD).Trim() if ([string]::IsNullOrWhiteSpace($revision)) { throw "could not resolve rubash revision" } $dirty = git -C ..\rubash status --porcelain if (-not [string]::IsNullOrWhiteSpace($dirty)) { throw "rubash checkout is dirty; a release must pin a committed revision`n$dirty" } $pinned = "${{ steps.rubash-pin.outputs.revision }}" if ([string]::IsNullOrWhiteSpace($pinned)) { throw "the pinned-rubash clone step produced no revision to cross-check" } if ($revision -ne $pinned) { throw "rubash checkout ($revision) drifted from the pinned revision ($pinned); refusing to build (rubash#827)" } Write-Host "rubash revision: $revision (matches the pin)" "revision=$revision" >> $env:GITHUB_OUTPUT - name: Build niubash env: RUSTFLAGS: "-C target-feature=+crt-static" run: cargo build --locked --release --target ${{ matrix.rust_target }} - name: Download pinned WinuxCmd release id: winuxcmd shell: pwsh env: GH_TOKEN: ${{ github.token }} WINUXCMD_TAG_INPUT: ${{ inputs.winuxcmd_tag }} WINUXCMD_SHA256_INPUT: ${{ inputs.winuxcmd_sha256 }} run: | $ErrorActionPreference = "Stop" $repo = "unixwin/WinuxCmd" $archKey = "windows-${{ matrix.arch }}" # niubash#312: the bundled WinuxCmd is PINNED, never "whatever the # ecosystem's latest release was at build time". Manual dispatches # must name the exact tag AND the expected zip SHA-256. Tag-push # releases read the pin committed at the tag # (scripts/release/winuxcmd-pin.json). Either way the downloaded zip # is hashed and must match before anything is extracted — a # mismatched or unpinned WinuxCmd fails the release. if ("${{ github.event_name }}" -eq "workflow_dispatch") { $requestedTag = $env:WINUXCMD_TAG_INPUT if ([string]::IsNullOrWhiteSpace($requestedTag)) { throw "winuxcmd_tag is required (niubash#312: releases must pin the WinuxCmd tag; no 'latest' fallback)" } if ([string]::IsNullOrWhiteSpace($env:WINUXCMD_SHA256_INPUT)) { throw "winuxcmd_sha256 is required (niubash#312: releases must assert the WinuxCmd zip SHA-256)" } $expectedSha = ($env:WINUXCMD_SHA256_INPUT | ConvertFrom-Json).$archKey } else { $pin = Get-Content -LiteralPath scripts/release/winuxcmd-pin.json -Raw | ConvertFrom-Json $requestedTag = $pin.winuxcmd_tag $expectedSha = $pin.sha256.$archKey } if ([string]::IsNullOrWhiteSpace($expectedSha)) { throw "no expected WinuxCmd zip SHA-256 pinned for $archKey; refusing to bundle an unverified WinuxCmd (niubash#312)" } # Exact-tag lookup only: no gh release view without a tag argument. $releaseJson = gh release view $requestedTag --repo $repo --json tagName,assets | ConvertFrom-Json $asset = $releaseJson.assets | Where-Object { $_.name -like "${{ matrix.winuxcmd_asset_pattern }}" } | Select-Object -First 1 if (-not $asset) { throw "No WinuxCmd asset matched ${{ matrix.winuxcmd_asset_pattern }} in $($releaseJson.tagName)" } $downloadDir = Join-Path $env:RUNNER_TEMP "winuxcmd-${{ matrix.arch }}" New-Item -ItemType Directory -Force -Path $downloadDir | Out-Null gh release download $releaseJson.tagName --repo $repo --pattern $asset.name --dir $downloadDir $zipPath = Join-Path $downloadDir $asset.name $actualSha = (Get-FileHash -LiteralPath $zipPath -Algorithm SHA256).Hash.ToLowerInvariant() if ($actualSha -ne $expectedSha.ToLowerInvariant()) { throw "WinuxCmd zip $($asset.name) SHA-256 mismatch: expected $expectedSha, got $actualSha (niubash#312)" } Write-Host "WinuxCmd $($releaseJson.tagName) $($asset.name) SHA-256 verified: $actualSha" $extractDir = Join-Path $downloadDir "extract" Expand-Archive -LiteralPath $zipPath -DestinationPath $extractDir -Force $winuxcmdExe = Get-ChildItem -LiteralPath $extractDir -Recurse -Filter winuxcmd.exe | Select-Object -First 1 if (-not $winuxcmdExe) { throw "winuxcmd.exe not found in $($asset.name)" } "tag=$($releaseJson.tagName)" >> $env:GITHUB_OUTPUT "asset=$($asset.name)" >> $env:GITHUB_OUTPUT "exe=$($winuxcmdExe.FullName)" >> $env:GITHUB_OUTPUT # niubash#189, extended to the #209 must-install bundle — release # pre-install, manifest-driven. wpm ships INSIDE winuxcmd.exe # (`winuxcmd wpm ...`), so the binary the step above extracted is all # the package manager this needs. The manifest # (scripts/release/preinstall.json) names the packages (gawk; plus # niugit / ripgrep / fd per #209 so `niu setup` full distro works on a # gitless machine), the extra command shims, and the owner-ordered # exclusions (no compression tools; goawk forbidden; `link` forbidden # forever — MSVC collision); tests/preinstall_manifest.rs pins that # policy. The staged root mirrors # the shipped layout exactly (\usr\bin\.exe hardlink shims # forwarding to \opt\\ payloads), and package-release.ps1 # copies both into the package. # # niubash#312 — PINNED pre-install: the committed manifest carries the # exact artifact SHA-256 per package and arch. Before installing, the # live wpm index this build resolves must agree with the pin; wpm then # verifies the downloaded zip against that same index entry, so # pin == index == downloaded bytes closes the chain and the release no # longer depends on the index state of the build minute. A missing or # mismatched pin is a LOUD fail-open skip (below) — the package ships # without pre-install rather than with unverified bytes. # # FAIL-CLOSED on x64, FAIL-OPEN on other legs (owner decision, # niubash#468): pre-installing is an enhancement, not a promise — on # the non-primary legs. A failed install (e.g. arm64 legs — the wpm # index carries windows-x64 artifacts only for these packages) is a # loud ::warning # plus a step-summary line, never a release blocker on the non-primary # legs; the verify step below hard-asserts only what this step reports # as installed, and the release body discloses the caveat. On x64 — # the primary user-facing leg — the same failure is FAIL-CLOSED and # stops the release outright (niubash#468): a silently skipped gawk # preinstall once shipped a 1.4.1 without opt\gawk and left # usr\bin\awk.exe/gawk.exe as 1.4.0-era copies. Transport retries # follow the repo convention (perfbudget's Fetch-Pinned): 3 attempts # with backoff, the local proxy as the final-attempt fallback via # wpm's --proxy flag. - name: Pre-install release packages (wpm, manifest-driven) id: preinstall shell: pwsh run: | $ErrorActionPreference = "Stop" $manifest = Get-Content -LiteralPath scripts/release/preinstall.json -Raw | ConvertFrom-Json $winuxcmdExe = "${{ steps.winuxcmd.outputs.exe }}" $root = Join-Path $env:RUNNER_TEMP "preinstall-root-${{ matrix.arch }}" $archKey = "windows-${{ matrix.arch }}" New-Item -ItemType Directory -Force -Path (Join-Path $root "usr\bin") | Out-Null Copy-Item -LiteralPath $winuxcmdExe -Destination (Join-Path $root "usr\bin\winuxcmd.exe") -Force # wpm info consults the root's LOCAL index only (a fresh root does # not auto-fetch — only install does), so fetch it once up front. # The pin checks below and the installs then resolve the SAME cached # index; a transport failure here is fail-open per-package below. $previousEap = $ErrorActionPreference $ErrorActionPreference = "Continue" $indexOutput = & $winuxcmdExe wpm index update --root $root 2>&1 $ErrorActionPreference = $previousEap $indexOutput | ForEach-Object { Write-Host "$_" } if ($LASTEXITCODE -ne 0) { Write-Host "::warning::wpm index update failed on ${{ matrix.arch }}; every package will fail its pin check and ship WITHOUT pre-install (fail-open, niubash#312)" } function Confirm-PackagePin([object]$package, [string]$pinArchKey) { # niubash#312: refuse to install bytes the committed manifest did # not pin. Reads the live index metadata via `wpm info --json`; # the subsequent install verifies its download against the same # index entry, so a pass here pins the exact shipped artifact. $expected = $package.sha256.$pinArchKey if (-not $expected) { Write-Host "::warning::package '$($package.name)' has no $pinArchKey sha256 pin in preinstall.json; skipping pre-install on ${{ matrix.arch }} (fail-open, niubash#312)" return $false } # Same stderr discipline as Install-Package below: capture the # whole output stream, EAP scoped to Continue so a native stderr # record cannot terminate the step before the fail-open path runs. $previousEap = $ErrorActionPreference $ErrorActionPreference = "Continue" $infoOutput = & $winuxcmdExe wpm info $package.name --root $root --json 2>&1 $ErrorActionPreference = $previousEap if ($LASTEXITCODE -ne 0) { $infoOutput | ForEach-Object { Write-Host "$_" } Write-Host "::warning::wpm info '$($package.name)' failed; cannot verify the preinstall pin (fail-open, niubash#312)" return $false } try { $info = (($infoOutput | ForEach-Object { "$_" }) -join "`n") | ConvertFrom-Json } catch { Write-Host "::warning::wpm info '$($package.name)' did not return parseable JSON; cannot verify the preinstall pin (fail-open, niubash#312)" return $false } $artifact = $info.artifacts.$pinArchKey if (-not $artifact -or -not $artifact.sha256) { Write-Host "::warning::wpm index carries no $pinArchKey artifact for '$($package.name)' (expected on this leg); skipping pre-install (fail-open)" return $false } if ($artifact.sha256.ToLowerInvariant() -ne $expected.ToLowerInvariant()) { Write-Host "::warning::preinstall pin mismatch for '$($package.name)' ($pinArchKey): manifest pins $expected but the live index resolves $($artifact.sha256); refusing to install unverified bytes (niubash#312)" return $false } Write-Host "pin verified: $($package.name) $($info.version) $pinArchKey sha256 match" return $true } function Install-Package([string]$name) { # Attempt 1-2: direct. Attempt 3: local proxy fallback # (repo convention, cf. GIT_PERF_FALLBACK_PROXY in the # perfbudget gate). foreach ($attempt in 1..3) { $wpmArgs = @("wpm", "install", $name, "--root", $root, "--yes") if ($attempt -eq 3) { $wpmArgs += @("--proxy", "http://127.0.0.1:7897") } # Capture wpm's stdout+stderr into a variable (never the # pipeline): a function's return value is its whole output # stream, and a failed install's error text must not turn the # returned $false into a truthy string array. EAP is scoped to # Continue around the native call because Windows PowerShell # 5.1 turns `2>&1` stderr records into terminating # NativeCommandErrors under Stop — fail-open must survive # wpm's error text on both pwsh 7 and 5.1. $previousEap = $ErrorActionPreference $ErrorActionPreference = "Continue" $output = & $winuxcmdExe @wpmArgs 2>&1 $ErrorActionPreference = $previousEap $output | ForEach-Object { Write-Host "$_" } if ($LASTEXITCODE -eq 0) { return $true } Write-Host "wpm install $name failed (attempt $attempt/3)" Start-Sleep -Seconds (5 * $attempt) } return $false } function New-CommandShim([string]$name) { $target = Join-Path $root "usr\bin\winuxcmd.exe" $shim = Join-Path $root "usr\bin\$name.exe" if (Test-Path -LiteralPath $shim) { return $true } try { New-Item -ItemType HardLink -Path $shim -Target $target -ErrorAction Stop | Out-Null } catch { # File systems without hardlink support still get a copy: the # shim dispatch only needs the winuxcmd.exe image by that name. $null = Copy-Item -LiteralPath $target -Destination $shim -Force } return (Test-Path -LiteralPath $shim) } $installed = @() $failed = @() foreach ($package in $manifest.packages) { # niubash#312: nothing installs unless the committed pin matches # the live index. A pin failure counts as a failed install — the # same loud, non-blocking fail-open path as a transport failure. if (-not (Confirm-PackagePin $package $archKey)) { $failed += $package.name continue } if (Install-Package $package.name) { $installed += $package.name Write-Host "pre-installed: $($package.name)" } else { $failed += $package.name Write-Host "::warning::pre-install of '$($package.name)' failed on ${{ matrix.arch }} after retries; shipping WITHOUT it (fail-open, niubash#189)" } } $shimsMade = @() foreach ($shim in $manifest.shims) { # A shim may only reference an installed package's payload. if ($installed -notcontains $shim.target) { continue } if (New-CommandShim $shim.name) { $shimsMade += $shim.name } else { Write-Host "::warning::shim '$($shim.name)' could not be materialized (fail-open)" } } # The package-shim forwarder ships beside winuxcmd.exe in the # WinuxCmd release zip and belongs in usr\bin of the product # package (niubash#466/#468). Preinstalled layout:shim commands # keep forwarding through winuxcmd.exe — it resolves # opt\\.exe itself and needs no shipped .wpm state — # but runtime `wpm install` and `wpm links rebuild` need the # forwarder present to link package shims and to heal stale # hardlink shims on upgrade. Stage it AFTER the installs: if it # sat in usr\bin during `wpm install`, package shims would link # to it and then depend on a .wpm\shims.map this package # deliberately does not ship. $forwarder = Join-Path (Split-Path -Path $winuxcmdExe -Parent) "winuxcmd-shim.exe" if (Test-Path -LiteralPath $forwarder) { Copy-Item -LiteralPath $forwarder -Destination (Join-Path $root "usr\bin\winuxcmd-shim.exe") -Force Write-Host "staged the package-shim forwarder winuxcmd-shim.exe" } else { Write-Host "::warning::winuxcmd-shim.exe not found beside $winuxcmdExe (the WinuxCmd zip should carry it since v1.1.15); layout:shim installs fall back to winuxcmd.exe forwarding" $failed += "winuxcmd-shim" } # niubash#468 — FAIL-CLOSED on the primary leg: a silently skipped # preinstall once shipped a gawk-less 1.4.1 and left # usr\bin\awk.exe/gawk.exe as 1.4.0-era copies of an older # winuxcmd.exe. x64 is the user-facing leg, so any failure here # must stop the release; other legs keep the loud-warning # fail-open contract (arm64 has no gawk/niugit artifacts by # design). if ("${{ matrix.arch }}" -eq "x64") { if ($failed.Count -gt 0) { throw "x64 pre-install is fail-closed (niubash#468): failed packages: $($failed -join ', ')" } $missingShims = @($manifest.shims | Where-Object { $shimsMade -notcontains $_.name } | ForEach-Object { $_.name }) if ($missingShims.Count -gt 0) { throw "x64 pre-install is fail-closed (niubash#468): manifest shims not materialized: $($missingShims -join ', ')" } } $summary = "### Pre-install (${{ matrix.arch }})`ninstalled: $(if ($installed) { $installed -join ', ' } else { 'none' })`nshims: $(if ($shimsMade) { $shimsMade -join ', ' } else { 'none' })`nfailed: $(if ($failed) { $failed -join ', ' } else { 'none' })" Add-Content -Path $env:GITHUB_STEP_SUMMARY -Value $summary "root=$root" >> $env:GITHUB_OUTPUT "installed=$($installed -join ',')" >> $env:GITHUB_OUTPUT "shims=$($shimsMade -join ',')" >> $env:GITHUB_OUTPUT "failed=$($failed -join ',')" >> $env:GITHUB_OUTPUT - name: Package release zip shell: pwsh run: | ./scripts/package-release.ps1 ` -Version "${{ steps.release.outputs.version }}" ` -WinuxCmdPath "${{ steps.winuxcmd.outputs.exe }}" ` -PreinstallRoot "${{ steps.preinstall.outputs.root }}" ` -Configuration release ` -Target "${{ matrix.rust_target }}" ` -Arch "${{ matrix.arch }}" # niubash#470: the staged tree carries real NTFS hardlinks (wpm's own # layout, restored after the Copy-Item staging sweep), and the two # hardlink-native companions must transport that faithfully. Measured # on the v1.4.2 x64 tree: .tar.gz (bsdtar) round-trips the hardlinks # exactly (git.exe family 6-way, awk 3-way, every file byte-identical); # 7-Zip 26.04 archives the CONTENT once (solid compression) but does # not restore hardlinks on extraction, so the .7z gate asserts # content-identity only. A failure here fails the release: an archive # that unpacks to anything other than the staged bytes never ships. - name: Verify hardlink-native archives shell: pwsh env: # fsutil hardlink list needs an elevated shell; GitHub Windows # runners provide one. HS_ARCHIVE_VERSION: ${{ steps.release.outputs.version }} HS_ARCHIVE_ARCH: ${{ matrix.arch }} run: | $ErrorActionPreference = "Stop" $version = $env:HS_ARCHIVE_VERSION $arch = $env:HS_ARCHIVE_ARCH $name = "niubash-v$version-win-$arch" # Absolute on purpose: the fingerprint function strips this prefix # from absolute FullName paths, so a relative root would cut # mid-path and every comparison would spuriously differ. $stage = (Resolve-Path "dist/$name").Path $tarGz = "dist/$name.tar.gz" $sevenZipArchive = "dist/$name.7z" foreach ($artifact in @($tarGz, $sevenZipArchive)) { if (-not (Test-Path -LiteralPath $artifact)) { throw "expected hardlink-native archive missing: $artifact" } } $extractTar = Join-Path $env:RUNNER_TEMP "archive-verify-tar" $extract7z = Join-Path $env:RUNNER_TEMP "archive-verify-7z" New-Item -ItemType Directory -Force -Path $extractTar, $extract7z | Out-Null tar -xf $tarGz -C $extractTar if ($LASTEXITCODE -ne 0) { throw "tar -xf failed on $tarGz" } $sevenZipCandidates = @() if ($env:ProgramFiles) { $sevenZipCandidates += Join-Path $env:ProgramFiles "7-Zip\7z.exe" } if (${env:ProgramFiles(x86)}) { $sevenZipCandidates += Join-Path ${env:ProgramFiles(x86)} "7-Zip\7z.exe" } $sevenZip = $sevenZipCandidates | Where-Object { Test-Path -LiteralPath $_ } | Select-Object -First 1 if (-not $sevenZip) { $sevenZip = (Get-Command 7z.exe -ErrorAction SilentlyContinue).Source } if (-not $sevenZip) { throw "7z.exe not found on the runner; cannot verify $sevenZipArchive" } & $sevenZip x $sevenZipArchive ("-o" + $extract7z) -y | Out-Null if ($LASTEXITCODE -ne 0) { throw "7z x failed on $sevenZipArchive" } # Byte-identity: every path in the staged tree must unpack to the # same length and SHA-256 from BOTH archives. function Get-TreeFingerprint([string]$root) { Get-ChildItem -LiteralPath $root -Recurse -File | ForEach-Object { "{0}|{1}|{2}" -f $_.FullName.Substring($root.Length + 1).ToLowerInvariant(), $_.Length, (Get-FileHash -LiteralPath $_.FullName -Algorithm SHA256).Hash } | Sort-Object } $expected = Get-TreeFingerprint $stage $fromTar = Get-TreeFingerprint (Join-Path $extractTar $name) $from7z = Get-TreeFingerprint (Join-Path $extract7z $name) $diffTar = Compare-Object -ReferenceObject $expected -DifferenceObject $fromTar if ($diffTar) { $diffTar | Select-Object -First 5 | ForEach-Object { Write-Host $_.InputObject } throw ".tar.gz round-trip is not byte-identical to the staged tree ($($diffTar.Count) differing entries)" } $diff7z = Compare-Object -ReferenceObject $expected -DifferenceObject $from7z if ($diff7z) { $diff7z | Select-Object -First 5 | ForEach-Object { Write-Host $_.InputObject } throw ".7z round-trip is not byte-identical to the staged tree ($($diff7z.Count) differing entries)" } Write-Host "content identity: .tar.gz OK, .7z OK ($($expected.Count) files)" # Hardlink restoration, .tar.gz only (7-Zip's limitation is # documented, not gated): the bash/sh shim family is shipped on # every leg, so its >=2 links are the universal probe; niugit's # git.exe family (6-way on a leg where it pre-installed) is the # payload probe this dedup exists for. function Get-HardLinkCount([string]$path) { $listing = fsutil hardlink list $path 2>&1 if ($LASTEXITCODE -ne 0) { throw "fsutil hardlink list failed for $path`: $listing" } return ($listing | Measure-Object -Line).Lines } $tarRoot = Join-Path $extractTar $name $bashProbe = Join-Path $tarRoot "winuxcmd\usr\bin\bash.exe" if (-not (Test-Path -LiteralPath $bashProbe)) { throw "winuxcmd\usr\bin\bash.exe missing from the .tar.gz extraction" } $bashLinks = Get-HardLinkCount $bashProbe if ($bashLinks -lt 2) { throw ".tar.gz did not restore hardlinks: winuxcmd\usr\bin\bash.exe has $bashLinks link(s), expected >= 2" } Write-Host "bash.exe shim family links in .tar.gz extraction: $bashLinks" $gitProbe = Join-Path $tarRoot "winuxcmd\usr\bin\git.exe" if (Test-Path -LiteralPath $gitProbe) { $gitLinks = Get-HardLinkCount $gitProbe if ($gitLinks -lt 2) { throw ".tar.gz did not restore hardlinks: winuxcmd\usr\bin\git.exe has $gitLinks link(s), expected >= 2" } Write-Host "git.exe family links in .tar.gz extraction: $gitLinks" } else { Write-Host "::warning::niugit not pre-installed on this leg; git.exe link probe skipped" } $zipBytes = (Get-Item "dist/$name.zip").Length $tarBytes = (Get-Item $tarGz).Length $sevenBytes = (Get-Item $sevenZipArchive).Length $table = "### Archive sizes ($arch)`n| asset | MB |`n|---|---|`n| zip | $([Math]::Round($zipBytes / 1MB, 2)) |`n| tar.gz (hardlink-native) | $([Math]::Round($tarBytes / 1MB, 2)) |`n| 7z | $([Math]::Round($sevenBytes / 1MB, 2)) |" Add-Content -Path $env:GITHUB_STEP_SUMMARY -Value $table # niubash#189 acceptance, enforced per-artifact: whatever the pre-install # step INSTALLED must work inside the staged package — `--version` on # every shim, and a bash-it plugin-shaped awk pipeline through the # PACKAGED niu.exe (the same `| awk '{print $1}'` shape the gradle # completion pipes through) plus a raw-byte line-ending assertion on # that pipeline (niubash#467 — PowerShell's native capture strips \r, # so string equality cannot see the CRLF regression; the probe reads # back the file the shell itself redirects to). What failed to install # only warns (fail-open); what installed but does not work fails the job. - name: Verify pre-installed packages in the staged release shell: pwsh run: | $ErrorActionPreference = "Stop" $manifest = Get-Content -LiteralPath scripts/release/preinstall.json -Raw | ConvertFrom-Json $stage = "dist/niubash-v${{ steps.release.outputs.version }}-win-${{ matrix.arch }}" $installed = "${{ steps.preinstall.outputs.installed }}" -split "," | Where-Object { $_ } $failed = "${{ steps.preinstall.outputs.failed }}" -split "," | Where-Object { $_ } foreach ($failure in $failed) { Write-Host "::warning::release ships without pre-installed '$failure' (${{ matrix.arch }}; users can run winuxcmd wpm install $failure after install)" } $binDir = Join-Path $stage "winuxcmd\usr\bin" # niubash#312 says this step hard-asserts only what the pre-install # step reported as INSTALLED (the fail-open contract): a package # skipped by design on this leg (no arch pin, pin mismatch) asserts # nothing. And wpm materializes a usr\bin shim per REGISTERED # COMMAND — the niugit package ships git.exe, ripgrep ships rg.exe — # never per package name, so the assertion targets each package's # `commands` from the manifest (mirroring the wpm index), with the # package name as the fallback for entries that carry none. function Assert-StagedShim([string]$name, [string]$packageName) { $exe = Join-Path $binDir "$name.exe" if (-not (Test-Path -LiteralPath $exe)) { throw "pre-install reported '$packageName' installed but $exe is missing from the staged package" } $out = & $exe --version 2>&1 if ($LASTEXITCODE -ne 0 -or -not "$out") { throw "$name --version failed inside the staged package (exit $LASTEXITCODE)" } Write-Host "$name --version: $($out | Select-Object -First 1)" } foreach ($package in $manifest.packages) { if ($installed -notcontains $package.name) { continue } $names = @($package.commands | Where-Object { $_ }) if ($names.Count -eq 0) { $names = @($package.name) } foreach ($name in $names) { Assert-StagedShim $name $package.name } } foreach ($shim in $manifest.shims) { # A manifest shim exists only when its target package installed # (the pre-install step materializes it under exactly that rule). if ($installed -notcontains $shim.target) { continue } Assert-StagedShim $shim.name $shim.target } if ($installed -contains "gawk") { $env:PATH = "$(Resolve-Path $binDir);$env:PATH" $niu = Join-Path $stage "niu.exe" $out = Write-Output "gradle-hash-input" | & $niu -c "awk '{print `$1}'" if ("$out" -ne "gradle-hash-input") { throw "plugin-shaped awk pipeline through the packaged niu.exe printed '$out' instead of 'gradle-hash-input'" } Write-Host "awk pipeline through packaged niu.exe: $out" # niubash#467 — the LF contract is on the BYTES, and a PowerShell # native-command capture silently strips \r, so the string check # above cannot see the regression (exactly how the text-mode # mingw gawk shipped). The bundled payload's CRT rewrites \n as # \r\n on write and folds \r\n into \n on read; the shipped fix # is the WinuxCmd forwarder's adapted-command argv splice # (-v BINMODE=3 for awk/gawk). Each probe lets niu redirect the # pipeline to a file itself and asserts the raw bytes — LF output # on both command faces (the wpm-made gawk shim and the manifest # awk shim forward through the same dispatcher), and an explicit # "\r\n" ORS landing exactly \r\n (not the \r\r\n doubling a # text-mode stdout produces). On a forwarder without the adapter # these fail the release — fail-closed, like the rest of this step. $probeDir = ($env:RUNNER_TEMP -replace '\\', '/') function Assert-AwkOutputBytes([string]$face, [string]$command, [byte[]]$expected, [string]$probe) { & $niu -c ($command + " > '" + $probe + "'") if ($LASTEXITCODE -ne 0) { throw "$face byte probe exited $LASTEXITCODE" } $hex = (([System.IO.File]::ReadAllBytes($probe)) | ForEach-Object { $_.ToString('x2') }) -join ' ' $want = ($expected | ForEach-Object { $_.ToString('x2') }) -join ' ' Remove-Item -LiteralPath $probe -Force -ErrorAction SilentlyContinue if ($hex -ne $want) { throw "$face stdout landed as [$hex], expected [$want] — the staged bundle ships a text-mode gawk (niubash#467); the bundled WinuxCmd must carry the adapted forwarder that splices -v BINMODE=3" } Write-Host "$face raw output bytes: $hex" } Assert-AwkOutputBytes "awk" "printf 'a\nb\n' | awk '{print `$1}'" ([byte[]](0x61, 0x0a, 0x62, 0x0a)) "$probeDir/niubash-awk-lf.bin" Assert-AwkOutputBytes "gawk" "printf 'a\nb\n' | gawk '{print `$1}'" ([byte[]](0x61, 0x0a, 0x62, 0x0a)) "$probeDir/niubash-gawk-lf.bin" Assert-AwkOutputBytes "awk" ("printf 'a\nb\n' | awk '" + 'BEGIN{ORS="\r\n"}{print $1}' + "'") ([byte[]](0x61, 0x0d, 0x0a, 0x62, 0x0d, 0x0a)) "$probeDir/niubash-awk-ors.bin" } - name: Verify bash/sh invocation faces in the staged release shell: pwsh run: | $ErrorActionPreference = "Stop" $stage = "dist/niubash-v${{ steps.release.outputs.version }}-win-${{ matrix.arch }}" $usrBin = Join-Path $stage "winuxcmd\usr\bin" # The behavioral face check runs the staged shims on THIS runner. # The arm64 leg cross-compiles on an x64 windows-2025 host, and # x64 Windows has no ARM64 user-mode emulator, so correctly-built # aarch64 shims can never start here (run 39233051251: "The # specified executable is not a valid application for this OS # platform" — the shims were aarch64 as the package demands; the # host simply cannot execute them). On such a foreign-arch leg the # gate degrades to a structural PE-machine check: bash.exe, # sh.exe, niu.exe and the staged winuxcmd.exe must each carry the # target arch's machine type — the property the behavioral check # would have exercised the invocation-face forwarding through. # The pre-install fail-open contract above already degrades the # same way (an arm64 wpm client cannot run on this host either). $targetMachines = @{ "x64" = 0x8664; "arm64" = 0xAA64 } $targetMachine = $targetMachines["${{ matrix.arch }}"] if (-not $targetMachine) { throw "unknown arch ${{ matrix.arch }}" } $hostArch = (Get-CimInstance -ClassName Win32_Processor | Select-Object -First 1).Architecture $canRunNatively = ($hostArch -eq 9 -and "${{ matrix.arch }}" -eq "x64") -or ($hostArch -eq 12 -and "${{ matrix.arch }}" -eq "arm64") # PE header machine field (IMAGE_FILE_MACHINE_*), parsed without # external tooling. function Get-PEMachine([string]$path) { $fs = [System.IO.File]::OpenRead($path) try { $br = New-Object System.IO.BinaryReader($fs) if ($br.ReadUInt16() -ne 0x5A4D) { throw "not an MZ image: $path" } $null = $fs.Seek(0x3C, [System.IO.SeekOrigin]::Begin) $peOffset = $br.ReadUInt32() $null = $fs.Seek($peOffset, [System.IO.SeekOrigin]::Begin) if ($br.ReadUInt32() -ne 0x00004550) { throw "not a PE image: $path" } return $br.ReadUInt16() } finally { $fs.Dispose() } } if ($canRunNatively) { foreach ($face in @("bash", "sh")) { $exe = Join-Path $usrBin "$face.exe" if (-not (Test-Path -LiteralPath $exe)) { throw "$face.exe is missing from the staged package at $exe" } $help = (& $exe --help 2>&1 | Out-String) if ($LASTEXITCODE -ne 0) { throw "$face --help exited $LASTEXITCODE inside the staged package" } if ($help -notmatch "GNU bash, version" -or $help -notmatch "GNU long options:") { throw "$face --help did not print the GNU usage form; the shim is not forwarding the invocation face`n$help" } $version = (& $exe --version 2>&1 | Out-String) if ($LASTEXITCODE -ne 0 -or $version -notmatch "GNU bash, version") { throw "$face --version did not print the GNU version banner" } $zero = (& $exe -c 'echo $0' 2>&1 | Out-String).Trim() if ($LASTEXITCODE -ne 0 -or $zero -ne $face) { throw "$face -c 'echo `$0' printed '$zero' instead of '$face'; the invocation face did not reach the packaged niu.exe" } Write-Host "$face --help: GNU usage OK; `$0 = $zero" } $niu = Join-Path $stage "niu.exe" $productHelp = (& $niu --help 2>&1 | Out-String) if ($LASTEXITCODE -ne 0 -or $productHelp -notmatch "Niubash ") { throw "the niu face no longer prints the product help" } Write-Host "niu --help: product help OK" } else { # Foreign-arch leg: structural verification only. Fails the job # on a wrong-arch shim (the v1.4.0-era failure shape: an x64 # shim smuggled into the arm64 package would run on the host # but ship broken), so the gate still pins the package's # architecture honestly. Write-Host "host arch $($hostArch) cannot execute ${{ matrix.arch }} binaries; running the structural PE-machine face check" foreach ($rel in @("winuxcmd\usr\bin\bash.exe", "winuxcmd\usr\bin\sh.exe", "niu.exe", "winuxcmd\usr\bin\winuxcmd.exe")) { $path = Join-Path $stage $rel if (-not (Test-Path -LiteralPath $path)) { throw "$rel is missing from the staged package at $path" } $machine = Get-PEMachine $path if ($machine -ne $targetMachine) { throw ("{0} carries PE machine 0x{1:X4}, expected 0x{2:X4} for the {3} package" -f ` $rel, $machine, $targetMachine, "${{ matrix.arch }}") } Write-Host ("{0}: PE machine 0x{1:X4} matches the {2} package" -f ` $rel, $machine, "${{ matrix.arch }}") } } - name: Install Inno Setup shell: pwsh run: choco install innosetup --no-progress -y - name: Build Inno Setup installer shell: pwsh run: | $isccCandidates = @( "${env:ProgramFiles(x86)}\Inno Setup 6\ISCC.exe", "$env:ProgramFiles\Inno Setup 6\ISCC.exe" ) $iscc = $isccCandidates | Where-Object { Test-Path -LiteralPath $_ } | Select-Object -First 1 if (-not $iscc) { throw "ISCC.exe not found after installing Inno Setup" } $sourceDir = Join-Path (Resolve-Path dist) "niubash-v${{ steps.release.outputs.version }}-win-${{ matrix.arch }}" $outputDir = Resolve-Path dist & $iscc installer\niubash.iss ` "/DAppVersion=${{ steps.release.outputs.version }}" ` "/DAppArch=${{ matrix.arch }}" ` "/DSourceDir=$sourceDir" ` "/DOutputDir=$outputDir" - name: Add latest-compatible asset aliases shell: pwsh run: | $versionedZip = "dist/niubash-v${{ steps.release.outputs.version }}-win-${{ matrix.arch }}.zip" $versionedSetup = "dist/niubash-v${{ steps.release.outputs.version }}-win-${{ matrix.arch }}-setup.exe" $versionedTarGz = "dist/niubash-v${{ steps.release.outputs.version }}-win-${{ matrix.arch }}.tar.gz" $versionedSevenZip = "dist/niubash-v${{ steps.release.outputs.version }}-win-${{ matrix.arch }}.7z" Copy-Item -LiteralPath $versionedZip -Destination "dist/niubash-win-${{ matrix.arch }}.zip" -Force Copy-Item -LiteralPath $versionedSetup -Destination "dist/niubash-win-${{ matrix.arch }}-setup.exe" -Force # niubash#470: the hardlink-native archives get the same # latest-compatible aliases as the formats that already had them. Copy-Item -LiteralPath $versionedTarGz -Destination "dist/niubash-win-${{ matrix.arch }}.tar.gz" -Force Copy-Item -LiteralPath $versionedSevenZip -Destination "dist/niubash-win-${{ matrix.arch }}.7z" -Force # niubash#311: every setup.exe ships a `.sha256` sidecar next to it, in # the coreutils " " form. `niu --self-update` fetches # the stable alias's sidecar and verifies the downloaded installer # against it before executing (fail-closed). The same digests feed the # winget manifest's InstallerSha256 and the release's SHA256SUMS. - name: Emit SHA-256 digest sidecars for the installers shell: pwsh run: | $ErrorActionPreference = "Stop" foreach ($file in @( "dist/niubash-v${{ steps.release.outputs.version }}-win-${{ matrix.arch }}-setup.exe", "dist/niubash-win-${{ matrix.arch }}-setup.exe" )) { if (-not (Test-Path -LiteralPath $file)) { throw "expected installer artifact missing: $file" } $hash = (Get-FileHash -LiteralPath $file -Algorithm SHA256).Hash.ToLowerInvariant() Set-Content -LiteralPath "$file.sha256" -Value "$hash $(Split-Path -Leaf $file)" -Encoding ascii Write-Host "$(Split-Path -Leaf $file): $hash" } - name: Upload package artifact uses: actions/upload-artifact@v7 with: name: niubash-v${{ steps.release.outputs.version }}-win-${{ matrix.arch }} path: | dist/niubash-v${{ steps.release.outputs.version }}-win-${{ matrix.arch }}.zip dist/niubash-v${{ steps.release.outputs.version }}-win-${{ matrix.arch }}.tar.gz dist/niubash-v${{ steps.release.outputs.version }}-win-${{ matrix.arch }}.7z dist/niubash-v${{ steps.release.outputs.version }}-win-${{ matrix.arch }}-setup.exe dist/niubash-v${{ steps.release.outputs.version }}-win-${{ matrix.arch }}-setup.exe.sha256 dist/niubash-win-${{ matrix.arch }}.zip dist/niubash-win-${{ matrix.arch }}.tar.gz dist/niubash-win-${{ matrix.arch }}.7z dist/niubash-win-${{ matrix.arch }}-setup.exe dist/niubash-win-${{ matrix.arch }}-setup.exe.sha256 if-no-files-found: error retention-days: 1 # Unix release legs. The engine's Linux port (rubash#225-#240) is closed # and the CI cross-check line is green, so the release pipeline ships what # CI compiles — but never unverified: each leg smoke-runs the exact binary # it uploads, on that OS, before any artifact leaves the job. A smoke # failure fails the job, so nothing untested can reach the release. # # No WinuxCmd bundling on Unix, and none of the Windows package-manager # surface either (standing platform red line): Unix has native coreutils, # and the compile-time cfg already keeps the Windows-only layer out of # non-Windows binaries. build-linux: name: build-linux (${{ matrix.arch }}) runs-on: ${{ matrix.os }} outputs: # Both matrix legs fetch the same PINNED rubash revision, so this # resolves to the same value in both; the pin steps fail the build if # the checkout is not exactly the pinned revision (rubash#827). rubash_revision: ${{ steps.rubash.outputs.revision }} strategy: fail-fast: false matrix: include: # gnu, not musl (released shape, recorded in docs/release.md): # musl is now compiled AND smoke-gated by `cross-target-smoke` in # ci.yml, so a musl leg no longer ships an untested libc surface — # but the released tarballs stay gnu until a consumer needs a # static build. Adding one is a delivery decision: add # rust_target: x86_64-unknown-linux-musl here. The glibc floor is # the build image's (2.35 on ubuntu-22.04): Ubuntu 22.04+, # Debian 12+. Move both legs forward together when the image # retires. - arch: x86_64 rust_target: x86_64-unknown-linux-gnu os: ubuntu-22.04 # Native arm64 runner (free for public repos), not cross/qemu: # it compiles natively AND runs the smoke gate natively — the # whole point of the honest gate. - arch: aarch64 rust_target: aarch64-unknown-linux-gnu os: ubuntu-22.04-arm steps: - uses: actions/checkout@v4.2.2 with: fetch-depth: 0 # Same pinned-rubash contract as build-windows (rubash#827): fetch # exactly scripts/release/rubash-pin.json's commit into ../rubash, where # the [patch] path override takes over, and die on any mismatch. - name: Checkout pinned rubash revision id: rubash-pin run: | set -euo pipefail rev="$(python3 -c 'import json,sys; print(json.load(open(sys.argv[1]))["rubash_rev"])' scripts/release/rubash-pin.json)" if [[ ! "$rev" =~ ^[0-9a-f]{40}$ ]]; then echo "rubash-pin.json: rubash_rev must be a full 40-char commit sha, got '$rev' (abbreviated forms cannot be fetched -- git resolves the fetch argument server-side as a ref name)" >&2 exit 1 fi if [ -e ../rubash ]; then echo "a rubash checkout already exists at ../rubash; refusing to pin over it" >&2 exit 1 fi git init -q ../rubash git -C ../rubash remote add origin https://github.com/unixwin/rubash.git if ! git -C ../rubash fetch --depth 1 origin "$rev"; then echo "could not fetch the pinned rubash revision $rev; if rubash master was rewritten upstream, bump scripts/release/rubash-pin.json (rubash#827)" >&2 exit 1 fi git -C ../rubash checkout --quiet FETCH_HEAD git -C ../rubash submodule update --init --depth 1 actual="$(git -C ../rubash rev-parse HEAD)" if [ "$actual" != "$rev" ]; then echo "rubash checkout resolved to $actual, not the pinned $rev; refusing to build (rubash#827)" >&2 exit 1 fi pin_version="$(python3 -c 'import json,sys; print(json.load(open(sys.argv[1]))["rubash_version"])' scripts/release/rubash-pin.json)" echo "rubash pinned at ${rev:0:12} (v$pin_version)" echo "revision=${rev:0:12}" >> "$GITHUB_OUTPUT" - name: Resolve release inputs id: release shell: bash run: | if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then tag="${{ inputs.tag }}" else tag="${{ github.ref_name }}" fi if [[ ! "$tag" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then echo "Invalid release tag '$tag'" >&2 exit 1 fi echo "tag=$tag" >> "$GITHUB_OUTPUT" echo "version=${tag#v}" >> "$GITHUB_OUTPUT" - name: Checkout release tag run: | git fetch --tags --force git checkout "${{ steps.release.outputs.tag }}" - name: Validate Cargo version matches release tag run: | line="$(grep -m1 -E '^version[[:space:]]*=' Cargo.toml)" cargo_version="$(printf '%s' "$line" | sed -E 's/^version[[:space:]]*=[[:space:]]*"([^"]+)".*$/\1/')" if [ "$cargo_version" != "${{ steps.release.outputs.version }}" ]; then echo "Cargo.toml version '$cargo_version' does not match release tag ${{ steps.release.outputs.tag }}" >&2 exit 1 fi - name: Install Rust toolchain uses: actions-rust-lang/setup-rust-toolchain@v1.11 with: toolchain: ${{ env.RUST_TOOLCHAIN }} # Same contract as build-windows: the pinned-clone step above already # fetched exactly the pinned commit; this step re-derives the revision, # refuses a dirty tree, and cross-checks the pin before anything is # compiled (rubash#827). The revision is embedded by build.rs, so # `niu --version` reports it. - name: Pin rubash revision id: rubash run: | if [ ! -d ../rubash ]; then echo "rubash checkout missing at ../rubash" >&2 exit 1 fi revision="$(git -C ../rubash rev-parse --short=12 HEAD)" if [ -z "$revision" ]; then echo "could not resolve rubash revision" >&2 exit 1 fi dirty="$(git -C ../rubash status --porcelain)" if [ -n "$dirty" ]; then echo "rubash checkout is dirty; a release must pin a committed revision" >&2 printf '%s\n' "$dirty" >&2 exit 1 fi pinned="${{ steps.rubash-pin.outputs.revision }}" if [ -z "$pinned" ]; then echo "the pinned-rubash clone step produced no revision to cross-check" >&2 exit 1 fi if [ "$revision" != "$pinned" ]; then echo "rubash checkout ($revision) drifted from the pinned revision ($pinned); refusing to build (rubash#827)" >&2 exit 1 fi echo "rubash revision: $revision (matches the pin)" echo "revision=$revision" >> "$GITHUB_OUTPUT" # No RUSTFLAGS here: crt-static is a Windows-leg choice; Linux gnu # binaries link the system glibc dynamically (the documented floor). - name: Build niubash run: cargo build --locked --release --target ${{ matrix.rust_target }} # HONEST GATE — smoke the binary this job just built, on this OS, # before anything is packaged or uploaded. A binary that cannot pass # these four checks on its build OS never becomes an artifact. - name: "Smoke-test built binary (gate: no smoke, no artifact)" run: | set -euo pipefail bin="target/${{ matrix.rust_target }}/release/niu" test -x "$bin" # 1) --version carries this release's version and the pinned engine # revision (exact substrings pinned by # tests/rubash_revision_banner.rs). out="$("$bin" --version)" printf '%s\n' "$out" grep -qF "Niubash ${{ steps.release.outputs.version }} " <<<"$out" grep -qF "rubash git ${{ steps.rubash.outputs.revision }}" <<<"$out" # 2) one-shot engine execution out="$("$bin" -c 'echo ok')" [ "$out" = "ok" ] # 3) external commands + pipeline through native coreutils out="$(printf 'a\nb\n' | "$bin" -c 'sort | head -1')" [ "$out" = "a" ] # 4) plugin stack offline check: empty spec, isolated HOME — # must exit 0 with zero output and touch no network. mkdir -p "$RUNNER_TEMP/smoke-home" : > "$RUNNER_TEMP/smoke-plugins.toml" out="$(HOME="$RUNNER_TEMP/smoke-home" \ NIU_PLUGIN_SPEC="$RUNNER_TEMP/smoke-plugins.toml" \ "$bin" plugin sync --bootstrap 2>&1)" if [ -n "$out" ]; then echo "plugin sync --bootstrap was not quiet on empty spec:" >&2 printf '%s\n' "$out" >&2 exit 1 fi face_dir="$RUNNER_TEMP/face" mkdir -p "$face_dir" cp "$bin" "$face_dir/bash" cp "$bin" "$face_dir/sh" face_help="$("$face_dir/bash" --help)" grep -qF "GNU bash, version" <<<"$face_help" grep -qF "GNU long options:" <<<"$face_help" face_help="$("$face_dir/sh" --help)" grep -qF "GNU bash, version" <<<"$face_help" grep -qF "GNU long options:" <<<"$face_help" face_version="$("$face_dir/bash" --version)" grep -qF "GNU bash, version" <<<"$face_version" face_version="$("$face_dir/sh" --version)" grep -qF "GNU bash, version" <<<"$face_version" [ "$("$face_dir/bash" -c 'echo $0')" = "bash" ] [ "$("$face_dir/sh" -c 'echo $0')" = "sh" ] [ "$("$bin" -c 'echo $0')" = "niu" ] # Record the libc floor this binary actually requires. ldd --version | head -1 - name: Package portable tarball run: | set -euo pipefail name="niubash-v${{ steps.release.outputs.version }}-linux-${{ matrix.arch }}" stage="dist/$name" mkdir -p "$stage" cp "target/${{ matrix.rust_target }}/release/niu" "$stage/niu" cp README.md LICENSE "$stage/" tar -czf "dist/$name.tar.gz" -C dist "$name" tar -tzf "dist/$name.tar.gz" - name: Upload package artifact uses: actions/upload-artifact@v7 with: name: niubash-v${{ steps.release.outputs.version }}-linux-${{ matrix.arch }} path: dist/niubash-v${{ steps.release.outputs.version }}-linux-${{ matrix.arch }}.tar.gz if-no-files-found: error retention-days: 1 # macos-13 (the Intel runner) is retired, so both macOS targets build on # the arm64 runner: aarch64 natively, x86_64 as a first-class Apple # cross-compile (same SDK), with its smoke gate run under Rosetta 2. build-macos: name: build-macos (${{ matrix.arch }}) runs-on: macos-latest outputs: # Both matrix legs fetch the same PINNED rubash revision, so this # resolves to the same value in both; the pin steps fail the build if # the checkout is not exactly the pinned revision (rubash#827). rubash_revision: ${{ steps.rubash.outputs.revision }} strategy: fail-fast: false matrix: include: - arch: aarch64 rust_target: aarch64-apple-darwin - arch: x86_64 rust_target: x86_64-apple-darwin rosetta: true steps: - uses: actions/checkout@v4.2.2 with: fetch-depth: 0 # Same pinned-rubash contract as build-windows (rubash#827): fetch # exactly scripts/release/rubash-pin.json's commit into ../rubash, where # the [patch] path override takes over, and die on any mismatch. - name: Checkout pinned rubash revision id: rubash-pin run: | set -euo pipefail rev="$(python3 -c 'import json,sys; print(json.load(open(sys.argv[1]))["rubash_rev"])' scripts/release/rubash-pin.json)" if [[ ! "$rev" =~ ^[0-9a-f]{40}$ ]]; then echo "rubash-pin.json: rubash_rev must be a full 40-char commit sha, got '$rev' (abbreviated forms cannot be fetched -- git resolves the fetch argument server-side as a ref name)" >&2 exit 1 fi if [ -e ../rubash ]; then echo "a rubash checkout already exists at ../rubash; refusing to pin over it" >&2 exit 1 fi git init -q ../rubash git -C ../rubash remote add origin https://github.com/unixwin/rubash.git if ! git -C ../rubash fetch --depth 1 origin "$rev"; then echo "could not fetch the pinned rubash revision $rev; if rubash master was rewritten upstream, bump scripts/release/rubash-pin.json (rubash#827)" >&2 exit 1 fi git -C ../rubash checkout --quiet FETCH_HEAD git -C ../rubash submodule update --init --depth 1 actual="$(git -C ../rubash rev-parse HEAD)" if [ "$actual" != "$rev" ]; then echo "rubash checkout resolved to $actual, not the pinned $rev; refusing to build (rubash#827)" >&2 exit 1 fi pin_version="$(python3 -c 'import json,sys; print(json.load(open(sys.argv[1]))["rubash_version"])' scripts/release/rubash-pin.json)" echo "rubash pinned at ${rev:0:12} (v$pin_version)" echo "revision=${rev:0:12}" >> "$GITHUB_OUTPUT" - name: Resolve release inputs id: release shell: bash run: | if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then tag="${{ inputs.tag }}" else tag="${{ github.ref_name }}" fi if [[ ! "$tag" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then echo "Invalid release tag '$tag'" >&2 exit 1 fi echo "tag=$tag" >> "$GITHUB_OUTPUT" echo "version=${tag#v}" >> "$GITHUB_OUTPUT" - name: Checkout release tag run: | git fetch --tags --force git checkout "${{ steps.release.outputs.tag }}" - name: Validate Cargo version matches release tag run: | line="$(grep -m1 -E '^version[[:space:]]*=' Cargo.toml)" cargo_version="$(printf '%s' "$line" | sed -E 's/^version[[:space:]]*=[[:space:]]*"([^"]+)".*$/\1/')" if [ "$cargo_version" != "${{ steps.release.outputs.version }}" ]; then echo "Cargo.toml version '$cargo_version' does not match release tag ${{ steps.release.outputs.tag }}" >&2 exit 1 fi - name: Install Rust toolchain uses: actions-rust-lang/setup-rust-toolchain@v1.11 with: toolchain: ${{ env.RUST_TOOLCHAIN }} - name: Install Rust target run: rustup target add ${{ matrix.rust_target }} # Same contract as build-windows: the pinned-clone step above already # fetched exactly the pinned commit; this step re-derives the revision, # refuses a dirty tree, and cross-checks the pin before anything is # compiled (rubash#827). The revision is embedded by build.rs, so # `niu --version` reports it. - name: Pin rubash revision id: rubash run: | if [ ! -d ../rubash ]; then echo "rubash checkout missing at ../rubash" >&2 exit 1 fi revision="$(git -C ../rubash rev-parse --short=12 HEAD)" if [ -z "$revision" ]; then echo "could not resolve rubash revision" >&2 exit 1 fi dirty="$(git -C ../rubash status --porcelain)" if [ -n "$dirty" ]; then echo "rubash checkout is dirty; a release must pin a committed revision" >&2 printf '%s\n' "$dirty" >&2 exit 1 fi pinned="${{ steps.rubash-pin.outputs.revision }}" if [ -z "$pinned" ]; then echo "the pinned-rubash clone step produced no revision to cross-check" >&2 exit 1 fi if [ "$revision" != "$pinned" ]; then echo "rubash checkout ($revision) drifted from the pinned revision ($pinned); refusing to build (rubash#827)" >&2 exit 1 fi echo "rubash revision: $revision (matches the pin)" echo "revision=$revision" >> "$GITHUB_OUTPUT" - name: Build niubash run: cargo build --locked --release --target ${{ matrix.rust_target }} # Rosetta 2 lets the arm64 runner execute the cross-compiled x86_64 # binary, so the smoke gate runs on real hardware either way. - name: Ensure Rosetta 2 (x86_64 leg) if: matrix.rosetta run: | if ! arch -x86_64 /usr/bin/true 2>/dev/null; then softwareupdate --install-rosetta --agree-to-license fi arch -x86_64 /usr/bin/true # HONEST GATE — same five checks as build-linux, on this OS. The # x86_64 leg runs them through `arch -x86_64` (Rosetta). The prefix # is an unquoted string (not an array): macOS steps may run under # bash 3.2, where expanding an empty array under `set -u` errors. - name: "Smoke-test built binary (gate: no smoke, no artifact)" run: | set -euo pipefail bin="target/${{ matrix.rust_target }}/release/niu" test -x "$bin" if [ "${{ matrix.rosetta }}" = "true" ]; then prefix="arch -x86_64" else prefix="" fi # 1) --version carries this release's version and the pinned engine # revision (exact substrings pinned by # tests/rubash_revision_banner.rs). out="$($prefix "$bin" --version)" printf '%s\n' "$out" grep -qF "Niubash ${{ steps.release.outputs.version }} " <<<"$out" grep -qF "rubash git ${{ steps.rubash.outputs.revision }}" <<<"$out" # 2) one-shot engine execution out="$($prefix "$bin" -c 'echo ok')" [ "$out" = "ok" ] # 3) external commands + pipeline through native coreutils out="$(printf 'a\nb\n' | $prefix "$bin" -c 'sort | head -1')" [ "$out" = "a" ] # 4) plugin stack offline check: empty spec, isolated HOME — # must exit 0 with zero output and touch no network. mkdir -p "$RUNNER_TEMP/smoke-home" : > "$RUNNER_TEMP/smoke-plugins.toml" out="$(HOME="$RUNNER_TEMP/smoke-home" \ NIU_PLUGIN_SPEC="$RUNNER_TEMP/smoke-plugins.toml" \ $prefix "$bin" plugin sync --bootstrap 2>&1)" if [ -n "$out" ]; then echo "plugin sync --bootstrap was not quiet on empty spec:" >&2 printf '%s\n' "$out" >&2 exit 1 fi face_dir="$RUNNER_TEMP/face" mkdir -p "$face_dir" cp "$bin" "$face_dir/bash" cp "$bin" "$face_dir/sh" face_help="$($prefix "$face_dir/bash" --help)" grep -qF "GNU bash, version" <<<"$face_help" grep -qF "GNU long options:" <<<"$face_help" face_help="$($prefix "$face_dir/sh" --help)" grep -qF "GNU bash, version" <<<"$face_help" grep -qF "GNU long options:" <<<"$face_help" face_version="$($prefix "$face_dir/bash" --version)" grep -qF "GNU bash, version" <<<"$face_version" face_version="$($prefix "$face_dir/sh" --version)" grep -qF "GNU bash, version" <<<"$face_version" [ "$($prefix "$face_dir/bash" -c 'echo $0')" = "bash" ] [ "$($prefix "$face_dir/sh" -c 'echo $0')" = "sh" ] [ "$($prefix "$bin" -c 'echo $0')" = "niu" ] # Record what this binary was smoke-verified on. sw_vers uname -m - name: Package portable tarball run: | set -euo pipefail name="niubash-v${{ steps.release.outputs.version }}-macos-${{ matrix.arch }}" stage="dist/$name" mkdir -p "$stage" cp "target/${{ matrix.rust_target }}/release/niu" "$stage/niu" cp README.md LICENSE "$stage/" tar -czf "dist/$name.tar.gz" -C dist "$name" tar -tzf "dist/$name.tar.gz" - name: Upload package artifact uses: actions/upload-artifact@v7 with: name: niubash-v${{ steps.release.outputs.version }}-macos-${{ matrix.arch }} path: dist/niubash-v${{ steps.release.outputs.version }}-macos-${{ matrix.arch }}.tar.gz if-no-files-found: error retention-days: 1 # Experimental mobile/embedded legs (roadmap niubash#207): Android (bionic) # and OpenHarmony (aarch64-unknown-linux-ohos). They are deliberately NOT in # the `release` job's needs and carry `continue-on-error` -- the same # incubation pattern the musl leg followed before #221 tightened it into a # real gate (check-only first, then build, then smoke, then release). # Nothing either job uploads reaches the GitHub Release; artifacts go to the # workflow run only. Tighten (drop continue-on-error, add to needs, then # extend the smoke gate) only after these run green for a while. build-android: name: build-android (${{ matrix.arch }}) runs-on: ubuntu-22.04 # Incubation: failures here surface as yellow on the run, they never # block a release. Remove once the leg has been green for a cycle. continue-on-error: true strategy: fail-fast: false matrix: include: - arch: aarch64 rust_target: aarch64-linux-android ndk_triple: aarch64-linux-android # rubash#456 fixed the armv7 bionic cfg surface (u16 S_IFMT, # i32 off_t), so this leg compiles; keep it optional alongside. # The NDK triple is armv7a (with the trailing a) — the rust # target name armv7-linux-androideabi must NOT be pasted onto the # NDK wrapper names. - arch: armv7 rust_target: armv7-linux-androideabi ndk_triple: armv7a-linux-androideabi steps: - uses: actions/checkout@v4.2.2 with: fetch-depth: 0 # Same pinned-rubash contract as the build jobs (rubash#827): fetch # exactly scripts/release/rubash-pin.json's commit into ../rubash, where # the [patch] path override takes over, and die on any mismatch. This # leg uploads nothing to the release, but what it compiles must still be # a named revision, not build-minute master HEAD. - name: Checkout pinned rubash revision id: rubash-pin run: | set -euo pipefail rev="$(python3 -c 'import json,sys; print(json.load(open(sys.argv[1]))["rubash_rev"])' scripts/release/rubash-pin.json)" if [[ ! "$rev" =~ ^[0-9a-f]{40}$ ]]; then echo "rubash-pin.json: rubash_rev must be a full 40-char commit sha, got '$rev'" >&2 exit 1 fi if [ -e ../rubash ]; then echo "a rubash checkout already exists at ../rubash; refusing to pin over it" >&2 exit 1 fi git init -q ../rubash git -C ../rubash remote add origin https://github.com/unixwin/rubash.git if ! git -C ../rubash fetch --depth 1 origin "$rev"; then echo "could not fetch the pinned rubash revision $rev; if rubash master was rewritten upstream, bump scripts/release/rubash-pin.json (rubash#827)" >&2 exit 1 fi git -C ../rubash checkout --quiet FETCH_HEAD git -C ../rubash submodule update --init --depth 1 actual="$(git -C ../rubash rev-parse HEAD)" if [ "$actual" != "$rev" ]; then echo "rubash checkout resolved to $actual, not the pinned $rev; refusing to build (rubash#827)" >&2 exit 1 fi pin_version="$(python3 -c 'import json,sys; print(json.load(open(sys.argv[1]))["rubash_version"])' scripts/release/rubash-pin.json)" echo "rubash pinned at ${rev:0:12} (v$pin_version)" echo "revision=${rev:0:12}" >> "$GITHUB_OUTPUT" - name: Resolve release inputs id: release shell: bash run: | if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then tag="${{ inputs.tag }}" else tag="${{ github.ref_name }}" fi if [[ ! "$tag" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then echo "Invalid release tag '$tag'" >&2 exit 1 fi echo "tag=$tag" >> "$GITHUB_OUTPUT" echo "version=${tag#v}" >> "$GITHUB_OUTPUT" - name: Checkout release tag run: | git fetch --tags --force git checkout "${{ steps.release.outputs.tag }}" - name: Install Rust toolchain uses: actions-rust-lang/setup-rust-toolchain@v1.11 with: toolchain: ${{ env.RUST_TOOLCHAIN }} target: ${{ matrix.rust_target }} - name: Pin rubash revision id: rubash run: | if [ ! -d ../rubash ]; then echo "rubash checkout missing at ../rubash" >&2 exit 1 fi revision="$(git -C ../rubash rev-parse --short=12 HEAD)" if [ -z "$revision" ]; then echo "could not resolve rubash revision" >&2 exit 1 fi dirty="$(git -C ../rubash status --porcelain)" if [ -n "$dirty" ]; then echo "rubash checkout is dirty; a release must pin a committed revision" >&2 printf '%s\n' "$dirty" >&2 exit 1 fi pinned="${{ steps.rubash-pin.outputs.revision }}" if [ -z "$pinned" ]; then echo "the pinned-rubash clone step produced no revision to cross-check" >&2 exit 1 fi if [ "$revision" != "$pinned" ]; then echo "rubash checkout ($revision) drifted from the pinned revision ($pinned); refusing to build (rubash#827)" >&2 exit 1 fi echo "rubash revision: $revision (matches the pin)" echo "revision=$revision" >> "$GITHUB_OUTPUT" # Real link, not check-only: the NDK provides the bionic clang driver # (rubash#456 fixed the cfg surface, this job proves it actually # links). API 24 is the floor the rust android targets document. - name: Install Android NDK id: ndk uses: nttld/setup-ndk@v1 with: ndk-version: r27c add-to-path: false - name: Build niubash (bionic, release) env: ANDROID_NDK_HOME: ${{ steps.ndk.outputs.ndk-path }} # NDK wrapper naming: clang drivers are -clang. The # armv7 triple is armv7a-linux-androideabi (r27c ships # armv7a-linux-androideabi24-clang; there is no # armv7-linux-androideabi24-clang — the missing trailing "a" is # what red the armv7 leg with "linker not found"). aarch64 has no # suffix in its triple, so its line is already the real name. CARGO_TARGET_AARCH64_LINUX_ANDROID_LINKER: ${{ steps.ndk.outputs.ndk-path }}/toolchains/llvm/prebuilt/linux-x86_64/bin/aarch64-linux-android24-clang CARGO_TARGET_ARMV7_LINUX_ANDROIDEABI_LINKER: ${{ steps.ndk.outputs.ndk-path }}/toolchains/llvm/prebuilt/linux-x86_64/bin/armv7a-linux-androideabi24-clang run: cargo build --locked --release --target ${{ matrix.rust_target }} # The runner cannot execute bionic binaries, so the four-check smoke # gate is impossible here. This step is the honest substitute for # THIS leg: prove a real ELF for the right ABI exists and say, on the # artifact itself, that no smoke ran. It must never be read as a pass # of the release smoke gate. - name: Verify ELF shape (no runtime smoke on this leg) run: | set -euo pipefail bin="target/${{ matrix.rust_target }}/release/niu" test -f "$bin" file "$bin" case "$(file -b "$bin")" in *aarch64*|*ARM*) : ;; *) echo "unexpected ELF arch for ${{ matrix.rust_target }}" >&2; exit 1 ;; esac cat >> "$GITHUB_STEP_SUMMARY" <<'EOF' Android leg: build + link verified, binary NOT smoke-run (no Android runtime on the runner). EOF - name: Package portable zip run: | set -euo pipefail name="niubash-v${{ steps.release.outputs.version }}-android-${{ matrix.arch }}" stage="dist/$name" mkdir -p "$stage" cp "target/${{ matrix.rust_target }}/release/niu" "$stage/niu" cp README.md LICENSE "$stage/" (cd dist && zip -r "$name.zip" "$name") unzip -l "dist/$name.zip" - name: Upload package artifact uses: actions/upload-artifact@v7 with: name: niubash-v${{ steps.release.outputs.version }}-android-${{ matrix.arch }} path: dist/niubash-v${{ steps.release.outputs.version }}-android-${{ matrix.arch }}.zip if-no-files-found: error retention-days: 7 # OpenHarmony leg (roadmap niubash#207). First version is CHECK-ONLY, on # purpose and out loud: `aarch64-unknown-linux-ohos` is Tier 2 with host # tools (same target cross-target-check in ci.yml gates), so `cargo check` # needs no OpenHarmony SDK -- but real LINKING needs the OHOS SDK's clang # wrapper, and there is no runner preinstall and no version-pinned, # reproducibly-fetchable SDK source this workflow can responsibly pin yet # (docs/src/platform-support.md "Packaging" documents what a real ship # looks like: an HNP payload inside a signed HAP, an app-store release). # Uploading a raw binary from a leg that cannot even link would violate # the repo's "no untested artifact" line harder than uploading nothing. # Follow the musl precedent: check now, add a pinned SDK + real link in a # later PR, smoke/ship last. The artifact this uploads is the check log. build-openharmony: name: build-openharmony (aarch64, check-only) runs-on: ubuntu-22.04 continue-on-error: true steps: - uses: actions/checkout@v4.2.2 with: fetch-depth: 0 # Same pinned-rubash contract as the build jobs (rubash#827): fetch # exactly scripts/release/rubash-pin.json's commit into ../rubash, where # the [patch] path override takes over, and die on any mismatch. This # leg uploads nothing to the release, but what it compiles must still be # a named revision, not build-minute master HEAD. - name: Checkout pinned rubash revision id: rubash-pin run: | set -euo pipefail rev="$(python3 -c 'import json,sys; print(json.load(open(sys.argv[1]))["rubash_rev"])' scripts/release/rubash-pin.json)" if [[ ! "$rev" =~ ^[0-9a-f]{40}$ ]]; then echo "rubash-pin.json: rubash_rev must be a full 40-char commit sha, got '$rev'" >&2 exit 1 fi if [ -e ../rubash ]; then echo "a rubash checkout already exists at ../rubash; refusing to pin over it" >&2 exit 1 fi git init -q ../rubash git -C ../rubash remote add origin https://github.com/unixwin/rubash.git if ! git -C ../rubash fetch --depth 1 origin "$rev"; then echo "could not fetch the pinned rubash revision $rev; if rubash master was rewritten upstream, bump scripts/release/rubash-pin.json (rubash#827)" >&2 exit 1 fi git -C ../rubash checkout --quiet FETCH_HEAD git -C ../rubash submodule update --init --depth 1 actual="$(git -C ../rubash rev-parse HEAD)" if [ "$actual" != "$rev" ]; then echo "rubash checkout resolved to $actual, not the pinned $rev; refusing to build (rubash#827)" >&2 exit 1 fi pin_version="$(python3 -c 'import json,sys; print(json.load(open(sys.argv[1]))["rubash_version"])' scripts/release/rubash-pin.json)" echo "rubash pinned at ${rev:0:12} (v$pin_version)" echo "revision=${rev:0:12}" >> "$GITHUB_OUTPUT" - name: Resolve release inputs id: release shell: bash run: | if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then tag="${{ inputs.tag }}" else tag="${{ github.ref_name }}" fi if [[ ! "$tag" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then echo "Invalid release tag '$tag'" >&2 exit 1 fi echo "tag=$tag" >> "$GITHUB_OUTPUT" echo "version=${tag#v}" >> "$GITHUB_OUTPUT" - name: Checkout release tag run: | git fetch --tags --force git checkout "${{ steps.release.outputs.tag }}" - name: Install Rust toolchain uses: actions-rust-lang/setup-rust-toolchain@v1.11 with: toolchain: ${{ env.RUST_TOOLCHAIN }} target: aarch64-unknown-linux-ohos - name: cargo check (aarch64-unknown-linux-ohos, no binary produced) run: | set -euo pipefail mkdir -p dist { echo "niubash ${{ steps.release.outputs.tag }} — aarch64-unknown-linux-ohos" echo "check-only leg: no binary is produced or shipped from this leg." echo "rustc $(rustc --version); target is Tier 2 with host tools." echo "---" cargo check --workspace --locked --target aarch64-unknown-linux-ohos 2>&1 } | tee "dist/ohos-check-log.txt" - name: Upload check log artifact uses: actions/upload-artifact@v7 with: name: niubash-v${{ steps.release.outputs.version }}-ohos-check-log path: dist/ohos-check-log.txt if-no-files-found: error retention-days: 7 journey: name: golden user journey (release gate) runs-on: windows-2025 timeout-minutes: 45 env: PYTHONIOENCODING: utf-8 PYTHONUTF8: "1" steps: - uses: actions/checkout@v4.2.2 with: fetch-depth: 0 # Same pinned-rubash contract as the build jobs (rubash#827): the gate # runs against the pinned engine, not build-minute master HEAD. - name: Checkout pinned rubash revision id: rubash-pin shell: pwsh run: | $ErrorActionPreference = "Stop" $pin = Get-Content -LiteralPath scripts/release/rubash-pin.json -Raw | ConvertFrom-Json $rev = $pin.rubash_rev if ($rev -notmatch '^[0-9a-f]{40}$') { throw "rubash-pin.json: rubash_rev must be a full 40-char commit sha, got '$rev'" } if (Test-Path -LiteralPath ..\rubash) { throw "a rubash checkout already exists at ..\rubash; refusing to pin over it" } git init ..\rubash if ($LASTEXITCODE -ne 0) { throw "git init ../rubash failed" } git -C ..\rubash remote add origin https://github.com/unixwin/rubash.git git -C ..\rubash fetch --depth 1 origin $rev if ($LASTEXITCODE -ne 0) { throw "could not fetch the pinned rubash revision $rev; if rubash master was rewritten upstream, bump scripts/release/rubash-pin.json (rubash#827)" } git -C ..\rubash checkout --quiet FETCH_HEAD if ($LASTEXITCODE -ne 0) { throw "could not check out the pinned rubash revision $rev" } git -C ..\rubash submodule update --init --depth 1 if ($LASTEXITCODE -ne 0) { throw "rubash submodules failed to initialize at the pinned revision" } $actual = (git -C ..\rubash rev-parse HEAD).Trim() if ($actual -ne $rev) { throw "rubash checkout resolved to $actual, not the pinned $rev; refusing to build (rubash#827)" } Write-Host "rubash pinned at $($rev.Substring(0,12)) (v$($pin.rubash_version))" - name: Resolve release inputs id: release shell: pwsh run: | if ("${{ github.event_name }}" -eq "workflow_dispatch") { $tag = "${{ inputs.tag }}" } else { $tag = "${{ github.ref_name }}" } if ($tag -notmatch '^v\d+\.\d+\.\d+$') { throw "Invalid release tag '$tag'" } "tag=$tag" >> $env:GITHUB_OUTPUT # The CURRENT gate (origin/master's whole scripts/journey) runs against # the tag's own source build — a workflow_dispatch of an old tag must # not run the tag's frozen copy (old tags predate the gate), and a tag # push must not run the tag's stale copy either. Delivered by git # pathspec, end to end (release run 38481728706): the RUNNER_TEMP/ # Copy-Item relay this replaces nested the directory at BOTH hops — # Copy-Item -Recurse copies INTO an existing destination, and the # Preserve hop pre-created RUNNER_TEMP\journey, so the restore copied # the already-nested parent and the gate landed at # scripts/journey/journey/ — the same nesting #327's remove-then-copy # fix had already documented for the restore hop alone (release runs # 2-5), now also hitting from the Preserve hop. The verdict # self-check then restored exactly one file to the right path and died # on the missing golden-journey.py beside it. - name: Checkout release tag shell: pwsh run: | git fetch --tags --force git checkout "${{ steps.release.outputs.tag }}" # Remove-then-checkout: the tag tree's own scripts/journey (if any) # must not leak stale files master has deleted; git pathspec # checkout overlays, it never deletes. if (Test-Path scripts/journey) { Remove-Item -Recurse -Force scripts/journey } git checkout origin/master -- scripts/journey - name: Install Rust toolchain uses: actions-rust-lang/setup-rust-toolchain@v1.11 with: toolchain: ${{ env.RUST_TOOLCHAIN }} - name: Build niu from the tag's own source run: cargo build --locked --release - name: Set up Python uses: actions/setup-python@v6 with: python-version: "3.12" - name: Install journey dependencies run: pip install pywinpty pyte # The verdict-to-exit contract the gate below runs under: labeled # known-fail exits 0 (labeling, never waiving — the owning ticket # owns the fix), an unlabeled fail exits 1. Guarded here so the # contract cannot drift silently between the module and the job. # Same deterministic delivery as the gate itself — the WHOLE directory, # not one file: the self-check loads golden-journey.py from beside # itself (test_verdict_exit.py:21), so delivering only the test file # left it executing against a module that was not there (release run # 38481728706). fetch-depth 0 guarantees origin/master is present; # the checkout above already delivered the same tree, this idempotent # re-assert keeps the self-check self-contained against reordering. - name: Verdict-exit contract self-check run: | git checkout origin/master -- scripts/journey python scripts/journey/test_verdict_exit.py - name: Run the golden user journey shell: pwsh run: | # Same delivery as the self-check above: scripts/journey straight # from origin/master's git object store (no temp dirs, no # Copy-Item semantics — runs 2-8, 38481728706). git checkout origin/master -- scripts/journey python scripts/journey/golden-journey.py ` (Resolve-Path target/release/niu.exe) ` --artifacts journey-artifacts - name: Upload journey verdict + transcripts if: always() uses: actions/upload-artifact@v7 with: name: journey-verdict path: journey-artifacts if-no-files-found: error retention-days: 14 # Per-asset timing budget gate (owner directive 2026-10-04): EVERY theme # and plugin asset of every trusted source gets a measured time on every # release. Contract since run 38489787748's calibration: an asset past # its hard cap (hard_fail_multiplier) in scripts/perf/budgets.toml FAILS # the release; a soft over (above budget, inside the hard band) prints # with its numbers as a warning — the first_prompt median moves ~30% # between reps of the SAME asset under concurrent lane builds, so the # soft band is runner load, not product. Same discipline as the journey # gate: the CURRENT gate runs against the tag's own build, with the # asset sources pinned so measured numbers stay comparable to the # committed baseline (scripts/perf/baselines/asset-timing-baseline.json). perfbudget: name: per-asset timing budget (release gate) runs-on: windows-2025 timeout-minutes: 75 env: PYTHONIOENCODING: utf-8 PYTHONUTF8: "1" steps: - uses: actions/checkout@v4.2.2 with: fetch-depth: 0 # Same pinned-rubash contract as the build jobs (rubash#827): the gate # runs against the pinned engine, not build-minute master HEAD. - name: Checkout pinned rubash revision id: rubash-pin shell: pwsh run: | $ErrorActionPreference = "Stop" $pin = Get-Content -LiteralPath scripts/release/rubash-pin.json -Raw | ConvertFrom-Json $rev = $pin.rubash_rev if ($rev -notmatch '^[0-9a-f]{40}$') { throw "rubash-pin.json: rubash_rev must be a full 40-char commit sha, got '$rev'" } if (Test-Path -LiteralPath ..\rubash) { throw "a rubash checkout already exists at ..\rubash; refusing to pin over it" } git init ..\rubash if ($LASTEXITCODE -ne 0) { throw "git init ../rubash failed" } git -C ..\rubash remote add origin https://github.com/unixwin/rubash.git git -C ..\rubash fetch --depth 1 origin $rev if ($LASTEXITCODE -ne 0) { throw "could not fetch the pinned rubash revision $rev; if rubash master was rewritten upstream, bump scripts/release/rubash-pin.json (rubash#827)" } git -C ..\rubash checkout --quiet FETCH_HEAD if ($LASTEXITCODE -ne 0) { throw "could not check out the pinned rubash revision $rev" } git -C ..\rubash submodule update --init --depth 1 if ($LASTEXITCODE -ne 0) { throw "rubash submodules failed to initialize at the pinned revision" } $actual = (git -C ..\rubash rev-parse HEAD).Trim() if ($actual -ne $rev) { throw "rubash checkout resolved to $actual, not the pinned $rev; refusing to build (rubash#827)" } Write-Host "rubash pinned at $($rev.Substring(0,12)) (v$($pin.rubash_version))" - name: Resolve release inputs id: release shell: pwsh run: | if ("${{ github.event_name }}" -eq "workflow_dispatch") { $tag = "${{ inputs.tag }}" } else { $tag = "${{ github.ref_name }}" } if ($tag -notmatch '^v\d+\.\d+\.\d+$') { throw "Invalid release tag '$tag'" } "tag=$tag" >> $env:GITHUB_OUTPUT # Preserve the CURRENT gate before checking out the tag (old tags # predate it), exactly like the journey job. - name: Preserve current perfbudget gate shell: pwsh run: | New-Item -ItemType Directory -Force -Path (Join-Path $env:RUNNER_TEMP "perf") | Out-Null Copy-Item -Recurse -LiteralPath scripts/perf -Destination (Join-Path $env:RUNNER_TEMP "perf") -Force - name: Checkout release tag shell: pwsh run: | git fetch --tags --force git checkout "${{ steps.release.outputs.tag }}" if (Test-Path scripts/perf) { Remove-Item -Recurse -Force scripts/perf } Copy-Item -Recurse -LiteralPath (Join-Path $env:RUNNER_TEMP "perf\perf") scripts/perf - name: Install Rust toolchain uses: actions-rust-lang/setup-rust-toolchain@v1.11 with: toolchain: ${{ env.RUST_TOOLCHAIN }} - name: Build niu from the tag's own source run: cargo build --locked --release - name: Set up Python uses: actions/setup-python@v6 with: python-version: "3.12" - name: Install perfbudget dependencies run: pip install pywinpty pyte # Pinned trusted sources — the SAME revisions the committed baseline # measured (ohmybash/oh-my-bash @ abf8461, Bash-it/bash-it @ 4725d29). # Bumping these pins changes every measured number and MUST come with # a re-recorded baseline in the same change. Fetch-by-SHA so the pin # stays honest even after the upstream branches move on. # # The pins are FULL 40-char object ids: a fetch REFSPEC is resolved # server-side as a ref NAME, so the old abbreviated form died before # measuring ("couldn't find remote ref abf8461", release run # 37224044818). TLS-resilient (wt87): 5 attempts with backoff; a # local operator whose git points at a dead proxy can set # GIT_PERF_FALLBACK_PROXY, tried as the final attempt (CI leaves it # unset — direct, like every other job). On total transport failure # the gate FAILS OPEN with a loud label: the missing source is # skipped (asset-timing.py measures the remaining tree; empty arg = # source disabled) and the verdict artifact carries # SKIPPED-.txt — a flap must not kill the gate before it # measures, and a skip must never look like a measurement. - name: Checkout pinned asset sources shell: pwsh run: | $ErrorActionPreference = "Stop" function Fetch-Pinned([string]$owner, [string]$repo, [string]$fullSha, [string]$dest) { # The fetch feeding the measurement was historically invisible # (that invisibility is how the 6s incident shipped): record # the wall time + attempt count per source into the verdict. $script:FetchLatency[$repo] = @{ pin = $fullSha started_utc = (Get-Date).ToUniversalTime().ToString("o") } $sw = [System.Diagnostics.Stopwatch]::StartNew() git init $dest git -C $dest remote add origin "https://github.com/$owner/$repo" $usedAttempts = 0 foreach ($attempt in 1..5) { $usedAttempts = $attempt git -C $dest fetch --depth 1 origin $fullSha if ($LASTEXITCODE -eq 0) { break } if ($env:GIT_PERF_FALLBACK_PROXY -and $attempt -eq 5) { git -C $dest -c http.proxy="$env:GIT_PERF_FALLBACK_PROXY" ` fetch --depth 1 origin $fullSha } Start-Sleep -Seconds (5 * $attempt) } $sw.Stop() $script:FetchLatency[$repo].seconds = [math]::Round($sw.Elapsed.TotalSeconds, 2) $script:FetchLatency[$repo].attempts = $usedAttempts $script:FetchLatency[$repo].ok = ($LASTEXITCODE -eq 0) if ($LASTEXITCODE -ne 0) { return $false } git -C $dest checkout --quiet FETCH_HEAD return $true } function Note-Skipped([string]$source, [string]$pin) { "source $source @ $pin could not be fetched after retries " + "(TLS/transport) - the budget gate runs WITHOUT it; this is " + "a SKIP, not a measurement" | Set-Content "perfbudget-artifacts\SKIPPED-$source.txt" Write-Host "::warning::perfbudget skipped $source @ $pin after retries (transport); see perfbudget-artifacts/SKIPPED-$source.txt" } $script:FetchLatency = @{} New-Item -ItemType Directory -Force -Path perfbudget-artifacts | Out-Null if (Fetch-Pinned "ohmybash" "oh-my-bash" ` "abf846186ab0a8a41ec5888e827ece6277dfe446" "..\oh-my-bash") { git -C ..\oh-my-bash checkout --quiet FETCH_HEAD } else { Note-Skipped "oh-my-bash" "abf8461" } if (Fetch-Pinned "Bash-it" "bash-it" ` "4725d29db8c0ac8c21df47664b28539f3b8fce94" "..\bash-it") { git -C ..\bash-it checkout --quiet FETCH_HEAD } else { Note-Skipped "bash-it" "4725d29" } $script:FetchLatency | ConvertTo-Json | Set-Content perfbudget-artifacts\fetch-latency.json - name: Run the per-asset timing budget gate shell: pwsh run: | git checkout origin/master -- scripts/perf $ombArg = if (Test-Path ..\oh-my-bash\.git) { (Resolve-Path ..\oh-my-bash).Path } else { "" } $bashitArg = if (Test-Path ..\bash-it\.git) { (Resolve-Path ..\bash-it).Path } else { "" } python scripts/perf/asset-timing.py ` --niu (Resolve-Path target/release/niu.exe) ` --omb $ombArg ` --bash-it $bashitArg ` --run-dir "$env:RUNNER_TEMP\perfbudget-run" ` --compare-baseline scripts/perf/baselines/asset-timing-baseline.json ` --out perfbudget-artifacts/asset-timing.json - name: Upload perfbudget verdict if: always() uses: actions/upload-artifact@v7 with: name: perfbudget-verdict path: perfbudget-artifacts if-no-files-found: error retention-days: 14 # AI agent skill bundle (niubash#188): the same tree `niu skill install` # embeds, zipped with the WinuxCmd-skill layout (one top-level directory # holding SKILL.md + references/ + agents/) so agent hosts can consume # either the release zip or the installed dir interchangeably. The golden # region check runs here too — a release never ships a drifted bundle. skill-bundle: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4.2.2 - name: Resolve release inputs id: release shell: bash run: | if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then tag="${{ inputs.tag }}" else tag="${{ github.ref_name }}" fi version="${tag#v}" echo "tag=$tag" >> "$GITHUB_OUTPUT" echo "version=$version" >> "$GITHUB_OUTPUT" - uses: actions/setup-python@v6 with: python-version: "3.12" - name: Golden-check the skill bundle run: python scripts/test-skill-bundle.py - name: Package niubash-skill zip run: | mkdir -p dist (cd skills && zip -r "../dist/niubash-skill-v${{ steps.release.outputs.version }}.zip" niubash) unzip -l "dist/niubash-skill-v${{ steps.release.outputs.version }}.zip" - name: Verify zip layout (SKILL.md at niubash/ root) run: | set -eu test "$(unzip -l "dist/niubash-skill-v${{ steps.release.outputs.version }}.zip" | grep -c 'niubash/SKILL.md')" -eq 1 unzip -l "dist/niubash-skill-v${{ steps.release.outputs.version }}.zip" | grep -q 'niubash/references/quickref.md' unzip -l "dist/niubash-skill-v${{ steps.release.outputs.version }}.zip" | grep -q 'niubash/agents/openai.yaml' - name: Upload skill bundle artifact uses: actions/upload-artifact@v7 with: name: niubash-skill-v${{ steps.release.outputs.version }} path: dist/niubash-skill-v${{ steps.release.outputs.version }}.zip if-no-files-found: error retention-days: 1 release: runs-on: ubuntu-latest needs: [build-windows, build-linux, build-macos, journey, perfbudget, skill-bundle] steps: - uses: actions/checkout@v4.2.2 - name: Resolve release inputs id: release shell: bash run: | if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then tag="${{ inputs.tag }}" else tag="${{ github.ref_name }}" fi version="${tag#v}" echo "tag=$tag" >> "$GITHUB_OUTPUT" echo "version=$version" >> "$GITHUB_OUTPUT" - name: Download package artifacts uses: actions/download-artifact@v8 with: path: artifacts # All build jobs fetch the same PINNED rubash revision # (scripts/release/rubash-pin.json), so they must have compiled the same # revision. If they did not (a pin file drift, a partial fetch), the # release would mix engines across platforms — refuse instead. - name: Check engine revision agreement across build jobs run: | set -eu revs="${{ needs.build-windows.outputs.rubash_revision }} ${{ needs.build-linux.outputs.rubash_revision }} ${{ needs.build-macos.outputs.rubash_revision }}" unique="$(printf '%s\n' $revs | sed '/^$/d' | sort -u | wc -l | tr -d ' ')" if [ "$unique" -ne 1 ]; then echo "build jobs compiled different rubash revisions:" >&2 printf '%s\n' $revs >&2 exit 1 fi echo "all platform builds compiled rubash git $(printf '%s\n' $revs | sed '/^$/d' | sort -u)" # niubash#311: one aggregate checksum file for the whole release. Payload # artifacts only — the per-installer .sha256 sidecars are listed BY NAME # here (hashing a hash would be circular), and the installer digests in # this file are the same values the winget manifest's InstallerSha256 # consumes. A missing payload set is an error, never an empty sums file. - name: Generate SHA256SUMS for every shipped artifact run: | set -euo pipefail : > SHA256SUMS count=0 while IFS= read -r file; do hash="$(sha256sum "$file" | awk '{print $1}')" printf '%s %s\n' "$hash" "$(basename "$file")" >> SHA256SUMS count=$((count + 1)) done < <(find artifacts -type f \( -name '*.zip' -o -name '*.exe' -o -name '*.tar.gz' -o -name '*.7z' \) | sort) if [ "$count" -eq 0 ]; then echo "no payload artifacts found to hash" >&2 exit 1 fi echo "hashed $count artifacts:" cat SHA256SUMS - name: Create GitHub Release uses: softprops/action-gh-release@v2 with: tag_name: ${{ steps.release.outputs.tag }} name: Niubash ${{ steps.release.outputs.tag }} body: | Niubash ${{ steps.release.outputs.tag }} packages for Windows, Linux, and macOS. Engine: rubash git ${{ needs.build-windows.outputs.rubash_revision }} — every platform build in this release compiled that same revision, pinned by `scripts/release/rubash-pin.json` and fail-hard verified at fetch time (rubash#827). The release compiles the pinned engine, not "whatever rubash master was when the pipeline ran". Verify locally with `niu --version`, which prints the same revision. Windows (x64, ARM64): Inno Setup installers (`*-setup.exe`) add user PATH and Windows Terminal profile integration; portable archives bundle `niu` with WinuxCmd and bash/sh shims. Portable download guide (niubash#470): prefer `*.tar.gz` — it is hardlink-native. The bundled payload ships real NTFS hardlinks (the whole `git.exe` family is one shared binary, `awk`/`gawk` likewise, exactly what `wpm install` lays down), so the tarball is roughly half the zip's download size and unpacks to a much smaller on-disk tree; `tar -xf .tar.gz` works out of the box on Windows 10 1803+ and any modern Linux/macOS, and on a filesystem without hardlink support (FAT32/exFAT) extraction degrades gracefully to independent byte-identical copies with no loss of function. `*.7z` is the smallest download (solid LZMA already collapses the shared payloads) but current 7-Zip releases restore the shared families as independent copies — every file is byte-identical, only disk usage grows. The classic `*.zip` is unchanged, stores every payload in full, and remains for tooling that only speaks zip. Windows packages come with the must-install bundle pre-installed (niubash#189, niubash#209): niugit (native Windows git — the full plugin distro's five entries install through git, so a fresh machine without git.exe no longer fails `niu setup`), gawk 5.4.1 with plain `awk` working too, plus ripgrep (`rg`) and fd — all landing in the bundled WinuxCmd root as wpm JSON-package payloads, assembled at CI time with no runtime downloads, no runtime archive formats, and no symlinks. Pre-install is best-effort, not a promise: on legs where the package index has no matching artifact (currently niugit and gawk on the ARM64 builds; ripgrep and fd have ARM64 artifacts) the package ships WITHOUT that entry — run `winuxcmd wpm install ` once after installing to add it. The pre-install list and its recorded exclusions live in `scripts/release/preinstall.json`. `niubash-skill-v.zip` is the AI agent skill bundle (SKILL.md + references, WinuxCmd-skill layout): unpack it into an agent skills directory, or let the shell do it — `niu skill install` embeds the same content. Linux (x86_64, aarch64; glibc): portable tarballs with the `niu` binary, README, and LICENSE — Unix builds bundle nothing, native system tools are used. Untar anywhere and run `./niu`. macOS (aarch64, x86_64): portable tarballs with the `niu` binary, README, and LICENSE. Untar anywhere and run `./niu`. Every artifact was smoke-verified on the build OS of the job that produced it, before upload: `niu -c 'echo ok'`, `printf 'a\nb\n' | niu -c 'sort | head -1'`, an offline `NIU_PLUGIN_SPEC= niu plugin sync --bootstrap` (exit 0, no output, no network), and a `niu --version` check against this tag and the engine revision. Integrity (niubash#311): `SHA256SUMS` lists the SHA-256 of every shipped artifact, and each Windows installer has a `.sha256` sidecar beside it. `niu --self-update` verifies the installer against the sidecar before running it and aborts rather than execute anything it cannot verify. The same digests feed the winget manifest `InstallerSha256`. files: | artifacts/**/*.zip artifacts/**/*.exe artifacts/**/*.tar.gz artifacts/**/*.7z artifacts/**/*.sha256 SHA256SUMS fail_on_unmatched_files: true overwrite_files: true generate_release_notes: true publish-crates: name: publish to crates.io runs-on: windows-latest # Ordering (v1.4.3 incident, run 39598031043): this job used to declare NO # needs and published to crates.io in parallel with the build and gate # jobs. A red gate then left the exact half-released state this repo hit: # crates.io already carried niubash 1.4.3 while the GitHub Release page # (the `release` job) had never run. The release job aggregates every # platform build and both release gates, so listing it first pins this # job behind the whole chain — build all platforms -> timing/journey gates # -> GitHub Release with all assets -> and only then crates.io. A publish # failure can still happen, but it can no longer race the gates or undo # the release page that already exists. needs: [release, build-windows, build-linux, build-macos, journey, perfbudget, skill-bundle] # Best-effort remains: with the ordering above a failure here no longer # blocks or half-does a release (rubash 1.4.2 is on crates.io since the # v1.4.3 era, so the version-annotated path deps now resolve). continue-on-error: true steps: - uses: actions/checkout@v4.2.2 with: fetch-depth: 0 # Same pinned-rubash contract as the build jobs (rubash#827): publishing # resolves against the named engine revision too. No submodules needed # here (same as before): nothing under third_party is imported. - name: Checkout pinned rubash revision id: rubash-pin shell: pwsh run: | $ErrorActionPreference = "Stop" $pin = Get-Content -LiteralPath scripts/release/rubash-pin.json -Raw | ConvertFrom-Json $rev = $pin.rubash_rev if ($rev -notmatch '^[0-9a-f]{40}$') { throw "rubash-pin.json: rubash_rev must be a full 40-char commit sha, got '$rev'" } if (Test-Path -LiteralPath ..\rubash) { throw "a rubash checkout already exists at ..\rubash; refusing to pin over it" } git init ..\rubash if ($LASTEXITCODE -ne 0) { throw "git init ../rubash failed" } git -C ..\rubash remote add origin https://github.com/unixwin/rubash.git git -C ..\rubash fetch --depth 1 origin $rev if ($LASTEXITCODE -ne 0) { throw "could not fetch the pinned rubash revision $rev; if rubash master was rewritten upstream, bump scripts/release/rubash-pin.json (rubash#827)" } git -C ..\rubash checkout --quiet FETCH_HEAD if ($LASTEXITCODE -ne 0) { throw "could not check out the pinned rubash revision $rev" } $actual = (git -C ..\rubash rev-parse HEAD).Trim() if ($actual -ne $rev) { throw "rubash checkout resolved to $actual, not the pinned $rev; refusing to build (rubash#827)" } Write-Host "rubash pinned at $($rev.Substring(0,12)) (v$($pin.rubash_version))" - name: Resolve release inputs id: release shell: pwsh run: | if ("${{ github.event_name }}" -eq "workflow_dispatch") { $tag = "${{ inputs.tag }}" } else { $tag = "${{ github.ref_name }}" } if ($tag -notmatch '^v\d+\.\d+\.\d+$') { throw "Invalid release tag '$tag'" } "tag=$tag" >> $env:GITHUB_OUTPUT # The crates guard below compares the registry's max_version against # this version; every other job's resolve step emits it too. (The # v1.4.3 re-tag run 39610082624 caught the omission: an empty # RELEASE_VERSION made the guard report "will publish" and only # cargo's own already-exists verdict kept the re-run green.) "version=$($tag.Substring(1))" >> $env:GITHUB_OUTPUT - name: Checkout release tag shell: pwsh run: | git fetch --tags --force git checkout "${{ steps.release.outputs.tag }}" - name: Install Rust toolchain uses: actions-rust-lang/setup-rust-toolchain@v1.11 with: toolchain: ${{ env.RUST_TOOLCHAIN }} # Idempotent re-run guard: crates.io rejects a second upload of the same # version, so a release re-run (the v1.4.3 recovery re-tag did exactly # this) must not even reach `cargo publish` when the version already # ships. Query the crates.io API up front: max_version == this release's # version means the crate is live — emit a notice and skip. An API # lookup failure is never a publish blocker: the flag stays false and # cargo publish's own idempotency verdicts (below) remain the backstop. - name: Guard - skip crates already published at this version id: guard env: RELEASE_VERSION: ${{ steps.release.outputs.version }} shell: pwsh run: | $ErrorActionPreference = "Stop" # crates.io requires a descriptive User-Agent on API traffic. $ua = "niubash-release-workflow/1.0 (https://github.com/unixwin/niubash)" foreach ($crate in @("niubash-runtime", "niubash")) { $already = $false $registryMax = "?" if ([string]::IsNullOrWhiteSpace($env:RELEASE_VERSION)) { # A resolve step that emitted no version makes the comparison # below meaningless; say so loudly and let cargo publish's own # idempotency verdicts decide instead of a bogus "will publish". Write-Host "::warning::RELEASE_VERSION is empty - the guard cannot compare against the registry; falling back to cargo publish's own idempotency verdicts" } else { try { foreach ($attempt in 1..3) { try { $json = Invoke-RestMethod -Uri "https://crates.io/api/v1/crates/$crate" ` -Headers @{ "User-Agent" = $ua } -TimeoutSec 30 -ErrorAction Stop break } catch { if ($attempt -eq 3) { throw } Start-Sleep -Seconds (5 * $attempt) } } $registryMax = $json.crate.max_version $already = ($registryMax -eq $env:RELEASE_VERSION) } catch { Write-Host "::warning::crates.io API lookup for $crate failed; falling back to cargo publish's own idempotency verdicts" } } "$($crate -replace '-', '_')_published=$already" >> $env:GITHUB_OUTPUT if ($already) { Write-Host "::notice::$crate $env:RELEASE_VERSION is already on crates.io (max_version match) - skipping the publish; the re-run stays green" } else { Write-Host "$crate $env:RELEASE_VERSION not live on crates.io yet (registry max_version=$registryMax); will publish" } } # Publishing needs version-annotated deps so cargo can swap them for # registry dependencies: `cargo publish` strips the [patch] section # from Cargo.toml, and a dependency without a version requirement # cannot resolve against the registry. rubash 1.4.2 is on crates.io # since the v1.4.3 era (the dep in Cargo.toml carries # version = "1.4.1"), so publish resolves fully offline of the pin. # # crates.io is idempotent per version: re-running a release (the # v1.4.0 run's crates job died on steps the first attempt had already # published) fails with one of two idempotency verdicts, both meaning # the version IS live on crates.io — a successful publish, not a # failure: # - HTTP 400 from the upload: "crate version `x.y.z` is already # uploaded" (the re-run this tolerance was built for), or # - the sparse-index pre-flight: "crate @ already # exists on crates.io index" (release run 38404767765 — cargo # sees the version in the index and refuses before any upload, # so the HTTP-400 text never appears). # Both steps grep for either phrasing and treat it as success; any # other failure still fails the step (loudly, under the job's # continue-on-error). The guard step above is the FIRST line of # defense — a re-run whose version already ships skips these steps # entirely via the `if:` conditions. - name: Publish niubash-runtime to crates.io if: steps.guard.outputs.runtime_published != 'true' shell: pwsh run: | $ErrorActionPreference = "Continue" $output = cargo publish --locked -p niubash-runtime --token ${{ secrets.CRATES_IO_TOKEN }} 2>&1 $output | ForEach-Object { Write-Host "$_" } if ($LASTEXITCODE -eq 0) { exit 0 } if (($output | ForEach-Object { "$_" }) -match "is already uploaded|already exists on crates\.io") { Write-Host "niubash-runtime's version is already on crates.io - the publish already happened; treating as success" exit 0 } exit 1 - name: Publish niubash to crates.io if: steps.guard.outputs.niubash_published != 'true' shell: pwsh run: | $ErrorActionPreference = "Continue" $output = cargo publish --locked -p niubash --token ${{ secrets.CRATES_IO_TOKEN }} 2>&1 $output | ForEach-Object { Write-Host "$_" } if ($LASTEXITCODE -eq 0) { exit 0 } if (($output | ForEach-Object { "$_" }) -match "is already uploaded|already exists on crates\.io") { Write-Host "niubash's version is already on crates.io - the publish already happened; treating as success" exit 0 } exit 1