Skip to content

Merge pull request #24 from ShikeChen-MS/fixes #23

Merge pull request #24 from ShikeChen-MS/fixes

Merge pull request #24 from ShikeChen-MS/fixes #23

Workflow file for this run

name: Build AOT CLI (cross-platform)
on:
workflow_dispatch:
inputs:
configuration:
description: 'Build configuration'
type: choice
required: true
default: 'Release'
options:
- Release
- Debug
# Run on every push to master so a regression never sits unbuilt.
# paths-ignore skips runs when a push contains ONLY documentation
# changes — Markdown files and the LICENSE. Any file outside this
# list (source, tests, workflows, project files, .gitignore, etc.)
# still triggers a full build so we don't accidentally let a code
# change merge unbuilt. If the next push reverts to a code change,
# the next scheduled run also re-exercises the full pipeline within
# 20 days at the latest.
push:
branches:
- master
paths-ignore:
- '**/*.md'
- 'LICENSE'
# Periodic refresh: runs at 06:00 UTC on the 1st and 21st of every
# month. Worst-case gap between runs is exactly 20 days (day 1 to
# day 21 within the same month), so the build is exercised at most
# 20 days apart even if nothing is pushed.
schedule:
- cron: '0 6 1,21 * *'
permissions:
contents: read
# Needed by actions/attest-build-provenance to mint the Sigstore OIDC
# token and to record the attestation in the repo's attestation log.
id-token: write
attestations: write
env:
PROJECT: src/JwtDecoder/JwtDecoder.csproj
PROJECT_JWKSFETCH: src/JwksFetch/JwksFetch.csproj
# Push and schedule events have no workflow inputs, so fall back to
# 'Release' for non-dispatch runs.
CONFIGURATION: ${{ inputs.configuration || 'Release' }}
jobs:
build:
name: ${{ matrix.label }}
runs-on: ${{ matrix.runner }}
strategy:
# Don't cancel siblings if one platform fails; we still want the others.
fail-fast: false
matrix:
include:
- label: windows-x64
runner: windows-latest
rid: win-x64
exe: jwtdecode.exe
jwksfetch_exe: jwksfetch.exe
- label: windows-arm64
runner: windows-11-arm
rid: win-arm64
exe: jwtdecode.exe
jwksfetch_exe: jwksfetch.exe
- label: linux-x64
runner: ubuntu-latest
rid: linux-x64
exe: jwtdecode
jwksfetch_exe: jwksfetch
- label: linux-arm64
runner: ubuntu-24.04-arm
rid: linux-arm64
exe: jwtdecode
jwksfetch_exe: jwksfetch
- label: macos-arm64
runner: macos-latest
rid: osx-arm64
exe: jwtdecode
jwksfetch_exe: jwksfetch
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup .NET SDKs (8 for Core/PS module, 10 for CLI + tests)
uses: actions/setup-dotnet@v4
with:
dotnet-version: |
8.0.x
10.0.x
- name: Show .NET info
shell: pwsh
run: dotnet --info
# Native AOT on Linux needs clang + zlib headers for the final native link.
# ubuntu-latest typically already has them; install is idempotent and cheap.
- name: Install Linux AOT dependencies
if: runner.os == 'Linux'
shell: bash
run: |
sudo apt-get update
sudo apt-get install -y clang zlib1g-dev
# Run the full unit-test suite (Core library + CLI argument parser)
# on this matrix entry's runner BEFORE any AOT publish or artifact
# upload. A failure here aborts the job and the artifact step never
# runs, so a regression cannot ship. Runs on every platform so a
# platform-specific crypto regression cannot slip past one runner.
#
# `-m:1` limits MSBuild to a single worker process to avoid a race
# where multiple test projects rebuild the shared JwtDecoder.Core
# in parallel and collide writing its intermediate dll / deps.json
# (reproduces on .NET SDK 10.0.301 Linux and Windows runners).
# Tests within a project still parallelise via xunit so wall-clock
# cost is negligible (build ~6s on a 16-core runner).
- name: Run unit tests (dotnet test)
shell: pwsh
run: dotnet test -c $env:CONFIGURATION --logger "console;verbosity=normal" -m:1
- name: Restore
shell: pwsh
run: dotnet restore "$env:PROJECT" -r ${{ matrix.rid }}
- name: Publish (Native AOT)
shell: pwsh
run: >
dotnet publish "$env:PROJECT"
-c $env:CONFIGURATION
-r ${{ matrix.rid }}
--no-restore
- name: Locate published binary
id: locate
shell: pwsh
run: |
$publishDir = "src/JwtDecoder/bin/$env:CONFIGURATION/net10.0/${{ matrix.rid }}/publish"
$bin = Join-Path $publishDir '${{ matrix.exe }}'
if (-not (Test-Path $bin)) {
Write-Error "Expected output not found: $bin"
if (Test-Path $publishDir) {
Get-ChildItem -Recurse $publishDir | Format-Table FullName, Length
}
exit 1
}
# Make sure the binary is executable on Unix runners (publish usually does
# this already, but we set it explicitly so the smoke test never fails for
# a permissions reason).
if ($IsLinux -or $IsMacOS) { chmod +x $bin }
$sizeMB = [math]::Round((Get-Item $bin).Length / 1MB, 2)
Write-Host "Built ${{ matrix.exe }} ($sizeMB MB) for ${{ matrix.rid }}"
"binPath=$bin" | Out-File -FilePath $env:GITHUB_OUTPUT -Append
# Every matrix entry runs on its own native architecture, so the
# functional tests run unconditionally. They exercise every supported
# algorithm family plus the documented security hardening (algorithm
# confusion, private-key refusal, curve binding, oversized input,
# duplicate header keys, terminal-injection guard, alg=none, ...).
- name: Functional tests (samples/)
shell: pwsh
run: ./tools/Test-AotBuild.ps1 -BinPath "${{ steps.locate.outputs.binPath }}"
# --------------------------------------------------------------
# Offline-guarantee verification (Phase 6).
# Asserts that JwtDecoder.Core.dll, the pre-AOT jwtdecode.dll, and
# the published native jwtdecode binary have no networking
# references. Four independent layers (managed IL grep via
# ilspycmd, native imports via dumpbin/objdump/otool, raw-bytes
# string scan, transitive NuGet package check) plus a SHA-256
# equality check between the file we inspect and the file we
# upload. All disassembly + imports listings are uploaded as a
# transparency artifact for downstream auditors.
# --------------------------------------------------------------
- name: Install ilspycmd
shell: pwsh
# Pin version for supply-chain hygiene; bump when validating a newer
# 10.x release of ICSharpCode/ILSpy.
run: dotnet tool install -g ilspycmd --version 10.1.0.8386
- name: Locate dumpbin (Windows only)
if: runner.os == 'Windows'
shell: pwsh
# The [B] layer of Verify-OfflineGuarantee.ps1 invokes `dumpbin`
# to inspect the AOT exe's native imports. dumpbin ships with
# MSVC but is NOT on PATH by default on either windows-latest
# or windows-11-arm; we have to locate it via vswhere and add
# the directory to GITHUB_PATH for the next step. Linux/macOS
# use objdump/otool which are always on PATH so this step is
# skipped there.
run: |
$vswhere = "${env:ProgramFiles(x86)}\Microsoft Visual Studio\Installer\vswhere.exe"
if (-not (Test-Path $vswhere)) {
Write-Error "vswhere.exe not found at $vswhere"
exit 1
}
$vsInstall = & $vswhere -latest -property installationPath
if (-not $vsInstall) {
Write-Error "No Visual Studio installation found by vswhere."
exit 1
}
# Prefer an architecture-native dumpbin (HostX64\x64 on x64,
# HostARM64\arm64 on arm64). Cross-host variants work too but
# native is faster and more compatible. Fall back to ANY
# dumpbin so a future MSVC layout change doesn't silently
# break the verifier.
$hostPattern = if ($env:RUNNER_ARCH -eq 'ARM64') {
'HostARM64(\\|/)arm64'
} else {
'Host[xX]64(\\|/)x64'
}
$candidates = Get-ChildItem -Path "$vsInstall\VC\Tools\MSVC" `
-Recurse -Filter 'dumpbin.exe' -ErrorAction SilentlyContinue
$dumpbin = $candidates | Where-Object { $_.FullName -match $hostPattern } | Select-Object -First 1
if (-not $dumpbin) { $dumpbin = $candidates | Select-Object -First 1 }
if (-not $dumpbin) {
Write-Error "dumpbin.exe not found under $vsInstall\VC\Tools\MSVC"
exit 1
}
Write-Host "Found dumpbin: $($dumpbin.FullName)"
Add-Content -Path $env:GITHUB_PATH -Value $dumpbin.Directory.FullName
- name: Verify offline guarantee (jwtdecode)
shell: pwsh
run: |
# Ensure the global tool dir is on PATH for this step.
$tools = if ($IsWindows) { Join-Path $env:USERPROFILE '.dotnet\tools' } else { Join-Path $env:HOME '.dotnet/tools' }
$env:PATH = "$tools$([System.IO.Path]::PathSeparator)$env:PATH"
# The pre-AOT managed dll lives at .../net10.0/<rid>/jwtdecode.dll.
$managedCli = "src/JwtDecoder/bin/$env:CONFIGURATION/net10.0/${{ matrix.rid }}/jwtdecode.dll"
if (-not (Test-Path $managedCli)) {
Write-Error "pre-AOT managed assembly not found at $managedCli"
exit 1
}
./tools/Verify-OfflineGuarantee.ps1 `
-CoreDllPath "src/JwtDecoder.Core/bin/$env:CONFIGURATION/net8.0/JwtDecoder.Core.dll" `
-ManagedCliDllPath $managedCli `
-AotExePath "${{ steps.locate.outputs.binPath }}" `
-DisasmOutDir "ci-artifacts/disasm-${{ matrix.rid }}" `
-UploadHashPath "${{ steps.locate.outputs.binPath }}"
- name: Upload offline-guarantee transparency artifact
uses: actions/upload-artifact@v4
with:
name: offline-guarantee-${{ matrix.rid }}-${{ env.CONFIGURATION }}-${{ github.sha }}
path: ci-artifacts/disasm-${{ matrix.rid }}
if-no-files-found: error
retention-days: 30
# Mint a Sigstore-backed build provenance attestation for the binary
# we just tested. Anyone who downloads the artifact can later run
# `gh attestation verify <file> --owner ShikeChen-MS` to confirm it
# came from this workflow, this repo, and this commit.
- name: Attest build provenance
uses: actions/attest-build-provenance@v2
with:
subject-path: ${{ steps.locate.outputs.binPath }}
- name: Upload artifact
uses: actions/upload-artifact@v4
with:
name: jwtdecode-${{ matrix.rid }}-${{ env.CONFIGURATION }}-${{ github.sha }}
path: ${{ steps.locate.outputs.binPath }}
if-no-files-found: error
retention-days: 30
# --------------------------------------------------------------
# jwksfetch: the network-capable companion CLI. Same matrix,
# same gating — `dotnet test` above must pass before we get
# here. Built, smoke-tested, attested, and uploaded as a
# SEPARATE artifact so the offline jwtdecode binary remains
# cleanly identifiable on its own.
# --------------------------------------------------------------
- name: Restore (jwksfetch)
shell: pwsh
run: dotnet restore "$env:PROJECT_JWKSFETCH" -r ${{ matrix.rid }}
- name: Publish jwksfetch (Native AOT)
shell: pwsh
run: >
dotnet publish "$env:PROJECT_JWKSFETCH"
-c $env:CONFIGURATION
-r ${{ matrix.rid }}
--no-restore
- name: Locate jwksfetch binary
id: locate_jwksfetch
shell: pwsh
run: |
$publishDir = "src/JwksFetch/bin/$env:CONFIGURATION/net10.0/${{ matrix.rid }}/publish"
$bin = Join-Path $publishDir '${{ matrix.jwksfetch_exe }}'
if (-not (Test-Path $bin)) {
Write-Error "Expected output not found: $bin"
if (Test-Path $publishDir) {
Get-ChildItem -Recurse $publishDir | Format-Table FullName, Length
}
exit 1
}
if ($IsLinux -or $IsMacOS) { chmod +x $bin }
$sizeMB = [math]::Round((Get-Item $bin).Length / 1MB, 2)
Write-Host "Built ${{ matrix.jwksfetch_exe }} ($sizeMB MB) for ${{ matrix.rid }}"
"binPath=$bin" | Out-File -FilePath $env:GITHUB_OUTPUT -Append
# Sample-driven functional tests for jwksfetch. Exercises help/version,
# argument validation, the --jwks-file happy path, an end-to-end pipe
# into jwtdecode --key-file -, HTTPS-only refusal, and kid-mismatch
# exit-code mapping. The artifact upload below only runs if every
# case passes.
- name: Functional tests (jwksfetch)
shell: pwsh
run: >
./tools/Test-JwksFetchBuild.ps1
-BinPath "${{ steps.locate_jwksfetch.outputs.binPath }}"
-JwtDecodeBinPath "${{ steps.locate.outputs.binPath }}"
- name: Attest build provenance (jwksfetch)
uses: actions/attest-build-provenance@v2
with:
subject-path: ${{ steps.locate_jwksfetch.outputs.binPath }}
- name: Upload artifact (jwksfetch)
uses: actions/upload-artifact@v4
with:
name: jwksfetch-${{ matrix.rid }}-${{ env.CONFIGURATION }}-${{ github.sha }}
path: ${{ steps.locate_jwksfetch.outputs.binPath }}
if-no-files-found: error
retention-days: 30