Skip to content

Release

Release #28

Workflow file for this run

name: Release
on:
push:
tags: ['v*']
workflow_dispatch:
inputs:
tag:
description: 'Existing release tag to recover for npm staging (for example v0.11.0)'
required: true
type: string
permissions:
contents: read
jobs:
build:
if: ${{ github.event_name == 'push' || (github.event_name == 'workflow_dispatch' && inputs.tag == 'v0.14.0') }}
strategy:
fail-fast: false
matrix:
include:
- runner: windows-latest
target: x86_64-pc-windows-msvc
asset: dev-windows-x86_64.exe
architecture: x64
- runner: windows-11-arm
target: aarch64-pc-windows-msvc
asset: dev-windows-aarch64.exe
architecture: arm64
runs-on: ${{ matrix.runner }}
permissions:
contents: read
id-token: write
attestations: write
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
fetch-depth: 0
persist-credentials: false
ref: ${{ inputs.tag || github.ref }}
- uses: dtolnay/rust-toolchain@6c977a6ca4077a0ceb28ffbe03f59d46e9ac8772 # upstream commit
with:
toolchain: 1.97.1
targets: ${{ matrix.target }}
- run: cargo build --release --target ${{ matrix.target }}
- run: cargo test --target ${{ matrix.target }}
- shell: pwsh
run: |
New-Item -ItemType Directory -Path release-assets -Force | Out-Null
Copy-Item "target/${{ matrix.target }}/release/dev.exe" "${{ matrix.asset }}"
Copy-Item "target/${{ matrix.target }}/release/dev.exe" "release-assets/dev.exe"
Copy-Item "powershell/DevNav.psm1" "release-assets/DevNav.psm1"
$version = "${{ inputs.tag || github.ref_name }}".TrimStart('v')
git fetch origin main --quiet
git merge-base --is-ancestor $env:GITHUB_SHA origin/main
if ($LASTEXITCODE -ne 0) { throw 'Release tags must point to a commit contained in main.' }
$cargoVersion = (Get-Content Cargo.toml | Select-String '^version = "([0-9.]+)"$').Matches.Groups[1].Value
if ($cargoVersion -ne $version) { throw "Tag version $version does not match Cargo.toml version $cargoVersion." }
$moduleManifestVersion = (Test-ModuleManifest -Path powershell/DevNav.psd1).Version.ToString()
if ($moduleManifestVersion -ne $version) { throw "Tag version $version does not match DevNav.psd1 version $moduleManifestVersion." }
$npmPackageVersion = (Get-Content packaging/npm/package.json -Raw | ConvertFrom-Json).version
if ($npmPackageVersion -ne $version) { throw "Tag version $version does not match packaging/npm/package.json version $npmPackageVersion." }
$scoopTemplateVersion = (Get-Content packaging/scoop/devnav.template.json -Raw | ConvertFrom-Json).version
if ($scoopTemplateVersion -ne $version) { throw "Tag version $version does not match packaging/scoop/devnav.template.json version $scoopTemplateVersion." }
& ./scripts/invoke-inno-compiler.ps1 -Architecture '${{ matrix.architecture }}' -Version $version
Copy-Item "release-assets/DevNavSetup-${{ matrix.architecture }}.exe" "DevNavSetup-${{ matrix.architecture }}.exe"
if ([version]$version -le [version]'0.9.7') {
throw "npm/Scoop channels must not publish v${version}; the first multichannel release must be newer than 0.9.7."
}
# Scoop owns the files it installs, so it gets a portable ZIP built
# from the same compiled bytes instead of the Inno installer. The
# marker file tells DevNav to defer updates to Scoop.
$scoopStage = Join-Path $env:RUNNER_TEMP "DevNav-scoop-${{ matrix.architecture }}"
New-Item -ItemType Directory -Path $scoopStage -Force | Out-Null
Copy-Item "release-assets/dev.exe" (Join-Path $scoopStage 'dev.exe')
Copy-Item "powershell/DevNav.psm1" (Join-Path $scoopStage 'DevNav.psm1')
Copy-Item "powershell/DevNav.psd1" (Join-Path $scoopStage 'DevNav.psd1')
New-Item -ItemType File -Path (Join-Path $scoopStage '.devnav-managed-by-scoop') -Force | Out-Null
Compress-Archive -Path (Join-Path $scoopStage '*') -DestinationPath "DevNav-scoop-${{ matrix.architecture }}.zip" -CompressionLevel Optimal -Force
- name: Attest built release artifacts
id: attest-release-artifacts
uses: actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6 # v4.2.2
with:
subject-path: |
${{ matrix.asset }}
DevNavSetup-${{ matrix.architecture }}.exe
DevNav-scoop-${{ matrix.architecture }}.zip
- name: Preserve build provenance bundle
shell: pwsh
run: |
Copy-Item "${{ steps.attest-release-artifacts.outputs.bundle-path }}" `
"DevNav-build-provenance-${{ matrix.architecture }}.sigstore.json" `
-ErrorAction Stop
# Extract the DSSE envelope from the Sigstore bundle into a
# standards-compliant .intoto.jsonl (one envelope per line). The
# script validates the envelope, the SLSA predicate and the
# expected subjects before the file can be published; the original
# Sigstore bundle with its verification material is kept above.
./scripts/convert-attestation-to-intoto.ps1 `
-BundlePath "${{ steps.attest-release-artifacts.outputs.bundle-path }}" `
-OutputPath "DevNav-build-provenance-${{ matrix.architecture }}.intoto.jsonl" `
-ExpectedSubject '${{ matrix.asset }}', "DevNavSetup-${{ matrix.architecture }}.exe", "DevNav-scoop-${{ matrix.architecture }}.zip"
- name: Install cosign
if: ${{ matrix.architecture != 'arm64' }}
uses: sigstore/cosign-installer@6f9f17788090df1f26f669e9d70d6ae9567deba6 # v4.1.2
with:
cosign-release: 'v3.1.3'
- name: Install cosign (ARM64 runner compatibility)
if: ${{ matrix.architecture == 'arm64' }}
shell: pwsh
run: |
$cosignDir = Join-Path $env:RUNNER_TEMP 'cosign'
New-Item -ItemType Directory -Path $cosignDir -Force | Out-Null
$cosignPath = Join-Path $cosignDir 'cosign.exe'
Invoke-WebRequest `
-Uri 'https://github.com/sigstore/cosign/releases/download/v3.1.3/cosign-windows-amd64.exe' `
-OutFile $cosignPath
$actual = (Get-FileHash -LiteralPath $cosignPath -Algorithm SHA256).Hash.ToLowerInvariant()
if ($actual -ne '9fe59be0eca1271873ce019061335eb1ac419b7059202e797828467ddabe33be') {
throw "Unexpected cosign digest: $actual"
}
Add-Content -LiteralPath $env:GITHUB_PATH -Value $cosignDir
- name: Sign release artifacts (Sigstore keyless)
shell: pwsh
run: |
$files = @(
'${{ matrix.asset }}',
"DevNavSetup-${{ matrix.architecture }}.exe",
"DevNav-scoop-${{ matrix.architecture }}.zip"
)
foreach ($f in $files) {
cosign sign-blob --yes --bundle "$f.sigstore.json" "$f"
if ($LASTEXITCODE -ne 0) { throw "cosign sign-blob failed for $f" }
}
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
with:
name: ${{ matrix.asset }}
path: |
${{ matrix.asset }}
DevNavSetup-${{ matrix.architecture }}.exe
DevNav-scoop-${{ matrix.architecture }}.zip
DevNav-build-provenance-${{ matrix.architecture }}.sigstore.json
DevNav-build-provenance-${{ matrix.architecture }}.intoto.jsonl
${{ matrix.asset }}.sigstore.json
DevNavSetup-${{ matrix.architecture }}.exe.sigstore.json
DevNav-scoop-${{ matrix.architecture }}.zip.sigstore.json
publish:
if: ${{ github.event_name == 'push' || (github.event_name == 'workflow_dispatch' && inputs.tag == 'v0.14.0') }}
needs: build
runs-on: ubuntu-latest
permissions:
contents: write
id-token: write
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: ${{ inputs.tag || github.ref }}
persist-credentials: false
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
with:
path: artifacts
merge-multiple: true
- run: cp powershell/DevNav.psm1 powershell/DevNav.psd1 artifacts/
- name: Generate immutable WinGet manifests
working-directory: artifacts
shell: bash
run: |
version="${{ inputs.tag || github.ref_name }}"
version="${version#v}"
mkdir -p manifests/j/JacobOptimiza/DevNav/"$version"
x64_sha=$(sha256sum DevNavSetup-x64.exe | cut -d' ' -f1)
arm64_sha=$(sha256sum DevNavSetup-arm64.exe | cut -d' ' -f1)
cat > manifests/j/JacobOptimiza/DevNav/"$version"/JacobOptimiza.DevNav.installer.yaml <<EOF
# yaml-language-server: \$schema=https://aka.ms/winget-manifest.installer.1.12.0.schema.json
PackageIdentifier: JacobOptimiza.DevNav
PackageVersion: $version
InstallerType: inno
Scope: user
Installers:
- Architecture: x64
InstallerUrl: https://github.com/JacobOptimiza/dev-nav/releases/download/v$version/DevNavSetup-x64.exe
InstallerSha256: $x64_sha
- Architecture: arm64
InstallerUrl: https://github.com/JacobOptimiza/dev-nav/releases/download/v$version/DevNavSetup-arm64.exe
InstallerSha256: $arm64_sha
ManifestType: installer
ManifestVersion: 1.12.0
EOF
cat > manifests/j/JacobOptimiza/DevNav/"$version"/JacobOptimiza.DevNav.locale.en-US.yaml <<EOF
# yaml-language-server: \$schema=https://aka.ms/winget-manifest.defaultLocale.1.12.0.schema.json
PackageIdentifier: JacobOptimiza.DevNav
PackageVersion: $version
PackageLocale: en-US
Publisher: Jacob Optimiza
PackageName: DevNav
License: MIT
ShortDescription: Native high-performance workspace navigator for PowerShell 7 on Windows.
PackageUrl: https://github.com/JacobOptimiza/dev-nav
LicenseUrl: https://github.com/JacobOptimiza/dev-nav/blob/main/LICENSE
ManifestType: defaultLocale
ManifestVersion: 1.12.0
EOF
cat > manifests/j/JacobOptimiza/DevNav/"$version"/JacobOptimiza.DevNav.yaml <<EOF
# yaml-language-server: \$schema=https://aka.ms/winget-manifest.version.1.12.0.schema.json
PackageIdentifier: JacobOptimiza.DevNav
PackageVersion: $version
DefaultLocale: en-US
ManifestType: version
ManifestVersion: 1.12.0
EOF
tar -a -c -f "winget-manifests-$version.zip" manifests
- name: Generate the Scoop manifest for this release
working-directory: artifacts
run: |
version="${{ inputs.tag || github.ref_name }}"
version="${version#v}"
hash() { sha256sum "$1" | cut -d' ' -f1; }
base="https://github.com/JacobOptimiza/dev-nav/releases/download/v$version"
jq --arg version "$version" \
--arg url_x64 "$base/DevNav-scoop-x64.zip" --arg sha_x64 "$(hash DevNav-scoop-x64.zip)" \
--arg url_arm64 "$base/DevNav-scoop-arm64.zip" --arg sha_arm64 "$(hash DevNav-scoop-arm64.zip)" \
'.version = $version | .architecture = {
"64bit": { url: $url_x64, hash: $sha_x64 },
"arm64": { url: $url_arm64, hash: $sha_arm64 }
}' "$GITHUB_WORKSPACE/packaging/scoop/devnav.template.json" > devnav.scoop.json
# Release payload artifacts are inventoried here; npm, Scoop and
# WinGet all derive from this single source of truth.
- name: Generate the release manifest
working-directory: artifacts
run: |
version="${{ inputs.tag || github.ref_name }}"
version="${version#v}"
hash() { sha256sum "$1" | cut -d' ' -f1; }
jq -n --arg version "$version" \
--arg winget_file "winget-manifests-$version.zip" \
--arg installer_x64_sha "$(hash DevNavSetup-x64.exe)" \
--arg installer_arm64_sha "$(hash DevNavSetup-arm64.exe)" \
--arg scoop_x64_sha "$(hash DevNav-scoop-x64.zip)" \
--arg scoop_arm64_sha "$(hash DevNav-scoop-arm64.zip)" \
--arg winget_sha "$(hash "winget-manifests-$version.zip")" \
--arg binary_x64_sha "$(hash dev-windows-x86_64.exe)" \
--arg binary_arm64_sha "$(hash dev-windows-aarch64.exe)" \
--arg module_sha "$(hash DevNav.psm1)" \
--arg module_manifest_sha "$(hash DevNav.psd1)" \
--arg scoop_manifest_sha "$(hash devnav.scoop.json)" \
'{
schemaVersion: 1,
version: $version,
artifacts: {
"installer-x64": { file: "DevNavSetup-x64.exe", sha256: $installer_x64_sha },
"installer-arm64": { file: "DevNavSetup-arm64.exe", sha256: $installer_arm64_sha },
"scoop-x64": { file: "DevNav-scoop-x64.zip", sha256: $scoop_x64_sha },
"scoop-arm64": { file: "DevNav-scoop-arm64.zip", sha256: $scoop_arm64_sha },
"winget-manifests": { file: $winget_file, sha256: $winget_sha },
"binary-x64": { file: "dev-windows-x86_64.exe", sha256: $binary_x64_sha },
"binary-arm64": { file: "dev-windows-aarch64.exe", sha256: $binary_arm64_sha },
"module": { file: "DevNav.psm1", sha256: $module_sha },
"module-manifest": { file: "DevNav.psd1", sha256: $module_manifest_sha },
"scoop-manifest": { file: "devnav.scoop.json", sha256: $scoop_manifest_sha }
}
}' > release-manifest.json
- working-directory: artifacts
run: sha256sum *.exe *.zip DevNav-build-provenance-x64.sigstore.json DevNav-build-provenance-arm64.sigstore.json DevNav-build-provenance-x64.intoto.jsonl DevNav-build-provenance-arm64.intoto.jsonl DevNav.psm1 DevNav.psd1 devnav.scoop.json release-manifest.json > SHA256SUMS.txt
- name: Install cosign
uses: sigstore/cosign-installer@6f9f17788090df1f26f669e9d70d6ae9567deba6 # v4.1.2
with:
cosign-release: 'v3.1.3'
# The build job already signed the compiled artifacts; sign here the
# files that only exist in this job, before the release is created.
- name: Sign publish-job artifacts (Sigstore keyless)
working-directory: artifacts
run: |
set -euo pipefail
for f in DevNav.psm1 DevNav.psd1 devnav.scoop.json release-manifest.json winget-manifests-*.zip SHA256SUMS.txt; do
cosign sign-blob --yes --bundle "$f.sigstore.json" "$f"
done
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
with:
name: release-manifest
path: artifacts/release-manifest.json
- name: Verify release provenance assets
working-directory: artifacts
shell: bash
run: |
set -euo pipefail
verify_provenance() {
local arch="$1"
local binary="$2"
local installer="$3"
local scoop="$4"
local file="DevNav-build-provenance-${arch}.intoto.jsonl"
test -s "$file"
test "$(grep -cve '^$' "$file")" -eq 1
jq -e '
has("payloadType") and
has("payload") and
has("signatures") and
(has("dsseEnvelope") | not) and
(has("verificationMaterial") | not)
' "$file" >/dev/null
statement="$(jq -r '.payload' "$file" | base64 --decode)"
jq -e '
._type == "https://in-toto.io/Statement/v1" and
(
.predicateType == "https://slsa.dev/provenance/v1" or
.predicateType == "https://slsa.dev/provenance/v0.2"
)
' <<<"$statement" >/dev/null
for artifact in "$binary" "$installer" "$scoop"; do
expected="$(
jq -r --arg name "$artifact" '
.subject[] |
select(.name == $name) |
.digest.sha256
' <<<"$statement"
)"
test -n "$expected"
test "$expected" != "null"
actual="$(sha256sum "$artifact" | cut -d' ' -f1)"
test "$expected" = "$actual"
done
}
verify_provenance \
x64 \
dev-windows-x86_64.exe \
DevNavSetup-x64.exe \
DevNav-scoop-x64.zip
verify_provenance \
arm64 \
dev-windows-aarch64.exe \
DevNavSetup-arm64.exe \
DevNav-scoop-arm64.zip
- env:
GH_TOKEN: ${{ github.token }}
# Upload files only; the unpacked manifests directory is not a release asset.
run: |
gh release create "${{ inputs.tag || github.ref_name }}" \
artifacts/*.exe \
artifacts/*.zip \
artifacts/*.psm1 \
artifacts/*.psd1 \
artifacts/SHA256SUMS.txt \
artifacts/DevNav-build-provenance-x64.sigstore.json \
artifacts/DevNav-build-provenance-arm64.sigstore.json \
artifacts/*.intoto.jsonl \
artifacts/*.exe.sigstore.json \
artifacts/*.zip.sigstore.json \
artifacts/*.psm1.sigstore.json \
artifacts/*.psd1.sigstore.json \
artifacts/*.json.sigstore.json \
artifacts/*.txt.sigstore.json \
artifacts/release-manifest.json \
artifacts/devnav.scoop.json \
--repo "${GITHUB_REPOSITORY}" --generate-notes --verify-tag
npm-package:
if: ${{ github.event_name == 'push' || (github.event_name == 'workflow_dispatch' && inputs.tag == 'v0.14.0') }}
needs: publish
runs-on: windows-latest
permissions:
contents: read
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: ${{ inputs.tag || github.ref }}
persist-credentials: false
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
# Node 24 is the canonical LTS baseline for release and npm
# publishing; Node 22 stays supported and Node 26 is exercised in CI
# for forward compatibility, but release ships on 24.
node-version: '24'
- name: Install verified npm CLI
uses: ./.github/actions/install-npm-cli
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
with:
path: artifacts
merge-multiple: true
- name: Assemble and verify the npm package
shell: pwsh
run: |
$ErrorActionPreference = 'Stop'
$version = "${{ inputs.tag || github.ref_name }}".TrimStart('v')
if ([version]$version -le [version]'0.9.7') {
throw "npm must not publish v${version}; the first multichannel release must be newer than 0.9.7."
}
$stage = 'npm-package'
New-Item -ItemType Directory -Path "$stage\payload" -Force | Out-Null
Copy-Item packaging\npm\package.json, packaging\npm\README.md $stage
Copy-Item packaging\npm\bin $stage -Recurse
Copy-Item LICENSE $stage
Copy-Item artifacts\DevNavSetup-x64.exe, artifacts\DevNavSetup-arm64.exe "$stage\payload"
Copy-Item artifacts\release-manifest.json $stage
$package = Get-Content "$stage\package.json" -Raw | ConvertFrom-Json
if ($package.version -ne $version) { throw "package.json version $($package.version) does not match tag version $version." }
if ($package.PSObject.Properties['dependencies'] -or $package.PSObject.Properties['devDependencies']) {
throw 'The bootstrap package must not declare dependencies.'
}
if ($package.PSObject.Properties['scripts'] -and $package.scripts.PSObject.Properties['postinstall']) {
throw 'postinstall scripts are forbidden in the bootstrap package.'
}
$manifest = Get-Content "$stage\release-manifest.json" -Raw | ConvertFrom-Json
if ($manifest.version -ne $version) { throw "release-manifest.json version $($manifest.version) does not match tag version $version." }
foreach ($name in @('installer-x64', 'installer-arm64')) {
$entry = $manifest.artifacts.$name
$file = Join-Path $stage "payload\$($entry.file)"
if (-not (Test-Path -LiteralPath $file -PathType Leaf)) { throw "Missing payload file $($entry.file)." }
$actualHash = (Get-FileHash -LiteralPath $file -Algorithm SHA256).Hash
if ($actualHash -ne $entry.sha256.ToUpperInvariant()) { throw "SHA-256 mismatch for $($entry.file)." }
}
- name: Inspect the tarball before packing
working-directory: npm-package
run: npm pack --dry-run
- name: Pack the tarball
working-directory: npm-package
run: npm pack
- name: Run the bootstrap unit tests
run: node --test "tests/npm/**/*.test.mjs"
- name: End-to-end install on x64
working-directory: npm-package
shell: pwsh
run: |
node bin/devnav.mjs install
if ($LASTEXITCODE -ne 0) { throw 'Bootstrap install failed.' }
- name: Install pinned package managers
shell: pwsh
run: |
npm install --global --allow-scripts=bun bun@1.3.14 pnpm@11.21.0
corepack enable
corepack prepare yarn@4.18.0 --activate
- name: Smoke test the documented ephemeral invocations
shell: pwsh
run: |
$ErrorActionPreference = 'Stop'
$version = "${{ inputs.tag || github.ref_name }}".TrimStart('v')
$tarball = (Resolve-Path "npm-package\jacoboptimiza-devnav-$version.tgz").Path
# Same forms the README documents, resolved against the local tarball
# instead of the registry.
$commands = [ordered]@{
'npm' = { & npx.cmd --yes --package $tarball devnav --version }
'bun' = { & bunx.cmd --bun "@jacoboptimiza/devnav@file:$tarball" --version }
'pnpm' = { & pnx.cmd $tarball --version }
'yarn' = {
& corepack.cmd yarn@4.18.0 dlx `
-p "@jacoboptimiza/devnav@file:$tarball" `
devnav --version
}
}
foreach ($manager in $commands.GetEnumerator()) {
$sandbox = Join-Path $env:RUNNER_TEMP "devnav-dlx-$($manager.Key)"
New-Item -ItemType Directory -Path $sandbox -Force | Out-Null
Push-Location $sandbox
try {
$output = & $manager.Value
if ($LASTEXITCODE -ne 0) { throw "$($manager.Key) ephemeral run failed." }
$versionLineFound = $false
foreach ($line in $output) {
if (([string]$line).Trim() -eq $version) {
$versionLineFound = $true
break
}
}
if (-not $versionLineFound) { throw "$($manager.Key) did not emit an exact version line: $output" }
}
finally {
Pop-Location
}
}
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
with:
name: npm-package-${{ inputs.tag || github.ref_name }}
path: npm-package/*.tgz
if-no-files-found: error
npm-publish:
if: ${{ (github.event_name == 'push' || (github.event_name == 'workflow_dispatch' && inputs.tag == 'v0.14.0')) && vars.NPM_TRUSTED_PUBLISHING_ENABLED == 'true' }}
needs: npm-package
runs-on: ubuntu-latest
environment: npm-production
permissions:
contents: read
id-token: write
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: ${{ inputs.tag || github.ref }}
persist-credentials: false
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: '24'
# Configure the npm registry so the tarball staging below publishes
# to npmjs through OIDC trusted publishing (no token is stored).
registry-url: 'https://registry.npmjs.org'
- name: Install verified npm CLI
uses: ./.github/actions/install-npm-cli
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
with:
name: npm-package-${{ inputs.tag || github.ref_name }}
path: npm-package
- name: Stage the exact tested tarball via OIDC trusted publishing
working-directory: npm-package
shell: bash
run: |
set -euo pipefail
tarball=(jacoboptimiza-devnav-*.tgz)
test "${#tarball[@]}" -eq 1
npm stage publish "./${tarball[0]}"
npm-recovery-package:
if: ${{ github.event_name == 'workflow_dispatch' && inputs.tag != 'v0.14.0' }}
runs-on: windows-latest
permissions:
contents: read
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: ${{ inputs.tag }}
fetch-depth: 0
persist-credentials: false
- name: Verify requested tag and download canonical release assets
shell: pwsh
env:
GH_TOKEN: ${{ github.token }}
RECOVERY_TAG: ${{ inputs.tag }}
run: |
$ErrorActionPreference = 'Stop'
$tagCommit = (git rev-list -n 1 "$env:RECOVERY_TAG^{commit}").Trim()
if (-not $tagCommit) { throw "Tag $env:RECOVERY_TAG does not exist." }
if ((git rev-parse HEAD).Trim() -ne $tagCommit) { throw 'Checkout is not exactly the requested tag.' }
New-Item -ItemType Directory -Path recovery-assets -Force | Out-Null
gh release download $env:RECOVERY_TAG --repo $env:GITHUB_REPOSITORY --dir recovery-assets --clobber `
--pattern 'DevNavSetup-x64.exe' `
--pattern 'DevNavSetup-arm64.exe' `
--pattern 'release-manifest.json'
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: '24'
- name: Install verified npm CLI
uses: ./.github/actions/install-npm-cli
- name: Assemble and verify the recovery package
shell: pwsh
env:
RECOVERY_TAG: ${{ inputs.tag }}
run: |
$ErrorActionPreference = 'Stop'
$version = $env:RECOVERY_TAG.TrimStart('v')
$stage = 'npm-recovery'
New-Item -ItemType Directory -Path "$stage\payload" -Force | Out-Null
Copy-Item packaging\npm\package.json, packaging\npm\README.md $stage
Copy-Item packaging\npm\bin $stage -Recurse
Copy-Item LICENSE $stage
Copy-Item recovery-assets\DevNavSetup-x64.exe, recovery-assets\DevNavSetup-arm64.exe "$stage\payload"
Copy-Item recovery-assets\release-manifest.json $stage
$package = Get-Content "$stage\package.json" -Raw | ConvertFrom-Json
if ($package.version -ne $version) { throw "package.json version $($package.version) does not match $version." }
if ($package.PSObject.Properties['dependencies'] -or $package.PSObject.Properties['devDependencies']) { throw 'Dependencies are forbidden.' }
if ($package.PSObject.Properties['scripts'] -and $package.scripts.PSObject.Properties['postinstall']) { throw 'postinstall is forbidden.' }
$manifest = Get-Content "$stage\release-manifest.json" -Raw | ConvertFrom-Json
if ($manifest.version -ne $version) { throw "release-manifest version $($manifest.version) does not match $version." }
foreach ($name in @('installer-x64', 'installer-arm64')) {
$entry = $manifest.artifacts.$name
$file = Join-Path $stage "payload\$($entry.file)"
if (-not (Test-Path -LiteralPath $file -PathType Leaf)) { throw "Missing $($entry.file)." }
$actualHash = (Get-FileHash -LiteralPath $file -Algorithm SHA256).Hash
if ($actualHash -ne $entry.sha256.ToUpperInvariant()) { throw "SHA-256 mismatch for $($entry.file)." }
}
- name: Inspect the recovery tarball before packing
working-directory: npm-recovery
run: npm pack --dry-run
- name: Pack the recovery tarball
working-directory: npm-recovery
run: npm pack
- name: Run the bootstrap unit tests
run: node --test "tests/npm/**/*.test.mjs"
- name: End-to-end install on x64
working-directory: npm-recovery
shell: pwsh
run: |
node bin/devnav.mjs install
if ($LASTEXITCODE -ne 0) { throw 'Bootstrap install failed.' }
- name: Install pinned package managers
shell: pwsh
run: |
npm install --global --allow-scripts=bun bun@1.3.14 pnpm@11.21.0
corepack enable
corepack prepare yarn@4.18.0 --activate
- name: Smoke test the recovery tarball
shell: pwsh
run: |
$ErrorActionPreference = 'Stop'
$version = "${{ inputs.tag }}".TrimStart('v')
$tarball = (Resolve-Path "npm-recovery\jacoboptimiza-devnav-$version.tgz").Path
$commands = [ordered]@{
'npm' = { & npx.cmd --yes --package $tarball devnav --version }
'bun' = { & bunx.cmd --bun "@jacoboptimiza/devnav@file:$tarball" --version }
'pnpm' = { & pnx.cmd $tarball --version }
'yarn' = {
& corepack.cmd yarn@4.18.0 dlx `
-p "@jacoboptimiza/devnav@file:$tarball" `
devnav --version
}
}
foreach ($manager in $commands.GetEnumerator()) {
$sandbox = Join-Path $env:RUNNER_TEMP "devnav-recovery-dlx-$($manager.Key)"
New-Item -ItemType Directory -Path $sandbox -Force | Out-Null
Push-Location $sandbox
try {
$output = & $manager.Value
if ($LASTEXITCODE -ne 0) { throw "$($manager.Key) recovery run failed." }
$versionLineFound = $false
foreach ($line in $output) {
if (([string]$line).Trim() -eq $version) { $versionLineFound = $true; break }
}
if (-not $versionLineFound) { throw "$($manager.Key) did not emit an exact version line: $output" }
}
finally { Pop-Location }
}
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
with:
name: npm-recovery-${{ inputs.tag }}
path: npm-recovery/*.tgz
if-no-files-found: error
npm-recovery-publish:
if: ${{ github.event_name == 'workflow_dispatch' && inputs.tag != 'v0.14.0' && vars.NPM_TRUSTED_PUBLISHING_ENABLED == 'true' }}
needs: npm-recovery-package
runs-on: ubuntu-latest
environment: npm-production
permissions:
contents: read
id-token: write
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: ${{ inputs.tag }}
persist-credentials: false
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: '24'
# Configure the npm registry so the recovery tarball staging below
# publishes to npmjs through OIDC trusted publishing (no token is
# stored).
registry-url: 'https://registry.npmjs.org'
- name: Install verified npm CLI
uses: ./.github/actions/install-npm-cli
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
with:
name: npm-recovery-${{ inputs.tag }}
path: npm-recovery
- name: Stage the exact recovery tarball via OIDC trusted publishing
working-directory: npm-recovery
shell: bash
run: |
set -euo pipefail
tarball=(jacoboptimiza-devnav-*.tgz)
test "${#tarball[@]}" -eq 1
npm stage publish "./${tarball[0]}"