Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions .github/ci.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
{
"runtimeRoot": ".",
"testMode": "gate",
"testEntry": "tests/Invoke-TestGate.ps1",
"tests": [
"tests/PraRemoteMailbox.Tests.ps1",
"tests/PraRemoteMailbox.Gui.Tests.ps1"
],
"packageBuilder": "tools/New-PraPackage.ps1",
"packageEntry": "Invoke-PraRemoteMailbox.ps1"
}
28 changes: 28 additions & 0 deletions .github/scripts/Initialize-CIModules.ps1
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
#Requires -Version 5.1
[CmdletBinding()]
param(
[Parameter(Mandatory)][string]$Destination
)

$ErrorActionPreference = 'Stop'
[void][IO.Directory]::CreateDirectory($Destination)
Save-Module -Name Pester -RequiredVersion 6.1.0 -Repository PSGallery -Path $Destination -Force -ErrorAction Stop
Save-Module -Name PSScriptAnalyzer -RequiredVersion 1.25.0 -Repository PSGallery -Path $Destination -Force -ErrorAction Stop

foreach ($module in @(
@{ Name = 'Pester'; Version = '6.1.0' },
@{ Name = 'PSScriptAnalyzer'; Version = '1.25.0' }
)) {
$manifest = Join-Path $Destination ('{0}\{1}\{0}.psd1' -f $module.Name, $module.Version)
if (-not (Test-Path -LiteralPath $manifest -PathType Leaf)) {
throw "The pinned module was not saved: $manifest"
}
$data = Import-PowerShellDataFile -LiteralPath $manifest
if ([string]$data.ModuleVersion -cne $module.Version) {
throw "The saved module version does not match: $manifest"
}
}

if (-not $env:GITHUB_ENV) { throw 'The GitHub Actions environment file is missing.' }
'CI_MODULE_PATH={0}' -f [IO.Path]::GetFullPath($Destination) |
Out-File -LiteralPath $env:GITHUB_ENV -Encoding utf8 -Append
150 changes: 150 additions & 0 deletions .github/scripts/Invoke-CI.ps1
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
#Requires -Version 5.1
[CmdletBinding()]
param(
[Parameter(Mandatory)][ValidateSet('Analyze', 'Test', 'Package')][string]$Stage,
[Parameter(Mandatory)][string]$OutputDirectory,
[string]$ModuleDirectory = $env:CI_MODULE_PATH
)

Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'
if ($PSVersionTable.PSEdition -eq 'Desktop') {
foreach ($name in 'Microsoft.PowerShell.Utility', 'Microsoft.PowerShell.Management', 'Microsoft.PowerShell.Security') {
Import-Module (Join-Path $PSHOME ('Modules\{0}\{0}.psd1' -f $name)) -ErrorAction Stop
}
}
$root = [IO.Path]::GetFullPath((Join-Path $PSScriptRoot '..\..'))
$configuration = Get-Content -LiteralPath (Join-Path $root '.github\ci.json') -Raw | ConvertFrom-Json
$OutputDirectory = [IO.Path]::GetFullPath($OutputDirectory)
[void][IO.Directory]::CreateDirectory($OutputDirectory)

if ($ModuleDirectory) {
$env:PSModulePath = $ModuleDirectory + [IO.Path]::PathSeparator + $env:PSModulePath
}

function Resolve-RepositoryFile {
param([Parameter(Mandatory)][string]$RelativePath)
if ([IO.Path]::IsPathRooted($RelativePath)) { throw 'A repository-relative path is required.' }
$path = [IO.Path]::GetFullPath((Join-Path $root $RelativePath))
if (-not $path.StartsWith($root.TrimEnd('\') + '\', [StringComparison]::OrdinalIgnoreCase) -or
-not (Test-Path -LiteralPath $path -PathType Leaf)) {
throw "Repository file missing or outside the repository: $RelativePath"
}
$path
}

switch ($Stage) {
'Analyze' {
Import-Module PSScriptAnalyzer -RequiredVersion 1.25.0 -Force -ErrorAction Stop
$tracked = @(git -C $root ls-files -- '*.ps1' '*.psm1' '*.psd1')
if ($LASTEXITCODE -ne 0) { throw 'Cannot enumerate the tracked PowerShell sources.' }
$runtime = [string]$configuration.runtimeRoot
$files = @($tracked | Where-Object {
($_ -notmatch '^(tests|tools|lib|\.github)/') -and
($runtime -eq '.' -or $_.StartsWith($runtime.TrimEnd('/') + '/', [StringComparison]::Ordinal))
})
if ($files.Count -eq 0) { throw 'No tracked runtime PowerShell sources were found.' }
$parseFailures = @()
$findings = @(foreach ($relative in $files) {
$path = Resolve-RepositoryFile $relative
$tokens = $null
$parseErrors = $null
$null = [Management.Automation.Language.Parser]::ParseFile($path, [ref]$tokens, [ref]$parseErrors)
foreach ($errorItem in $parseErrors) {
$parseFailures += [pscustomobject]@{
File = $relative; Line = $errorItem.Extent.StartLineNumber; Message = $errorItem.Message
}
}
foreach ($finding in @(Invoke-ScriptAnalyzer -Path $path -ErrorAction Stop)) {
[pscustomobject]@{
File = $relative; Line = $finding.Line; Column = $finding.Column
Severity = [string]$finding.Severity; Rule = $finding.RuleName; Message = $finding.Message
}
}
})
ConvertTo-Json -InputObject $findings -Depth 5 |
Out-File -LiteralPath (Join-Path $OutputDirectory 'script-analyzer.json') -Encoding utf8
ConvertTo-Json -InputObject $parseFailures -Depth 5 |
Out-File -LiteralPath (Join-Path $OutputDirectory 'syntax-errors.json') -Encoding utf8
$summary = [ordered]@{
Files = $files.Count; Analyzer = '1.25.0'; SyntaxErrors = $parseFailures.Count
Errors = @($findings | Where-Object Severity -eq 'Error').Count
Warnings = @($findings | Where-Object Severity -eq 'Warning').Count
Informative = @($findings | Where-Object Severity -eq 'Information').Count
DiagnosticsPolicy = 'Advisory; syntax or analyzer execution failures still fail the job.'
}
$summary | ConvertTo-Json |
Out-File -LiteralPath (Join-Path $OutputDirectory 'analysis-summary.json') -Encoding utf8
Write-Output ($summary | ConvertTo-Json -Compress)
if ($parseFailures.Count) { throw 'Runtime PowerShell sources contain syntax errors.' }
}
'Test' {
Import-Module Pester -RequiredVersion 6.1.0 -Force -ErrorAction Stop
if ($configuration.testMode -eq 'gate') {
$gate = Resolve-RepositoryFile $configuration.testEntry
$executable = if ($PSVersionTable.PSEdition -eq 'Desktop') {
Join-Path $PSHOME 'powershell.exe'
} else { Join-Path $PSHOME 'pwsh.exe' }
& $executable -NoProfile -NonInteractive -File $gate
if ($LASTEXITCODE -ne 0) { throw "The existing project test gate failed with exit code $LASTEXITCODE." }
} elseif ($configuration.testMode -eq 'runner') {
$runner = Resolve-RepositoryFile $configuration.testEntry
& {
# Preserve the existing runner's normal caller semantics.
Set-StrictMode -Off
& $runner
}
} elseif ($configuration.testMode -eq 'pester') {
$testFiles = @($configuration.tests | ForEach-Object { Resolve-RepositoryFile $_ })
if ($testFiles.Count -eq 0) { throw 'No test files were configured.' }
$pester = New-PesterConfiguration
$pester.Run.Path = $testFiles
$pester.Run.PassThru = $true
$pester.Run.Exit = $false
$pester.Output.Verbosity = 'Normal'
$pester.TestResult.Enabled = $true
$pester.TestResult.OutputFormat = 'NUnitXml'
$pester.TestResult.OutputPath = Join-Path $OutputDirectory 'pester-results.xml'
$result = & {
# Do not impose this wrapper's StrictMode on existing tests.
Set-StrictMode -Off
Invoke-Pester -Configuration $pester
}
if ($null -eq $result -or $result.TotalCount -le 0 -or $result.PassedCount -le 0 -or
$result.FailedCount -gt 0 -or $result.FailedBlocksCount -gt 0 -or
$result.FailedContainersCount -gt 0 -or [string]$result.Result -ne 'Passed') {
throw 'The project tests did not complete successfully.'
}
[ordered]@{
Total = $result.TotalCount; Passed = $result.PassedCount
Failed = $result.FailedCount; Skipped = $result.SkippedCount
} | ConvertTo-Json | Out-File -LiteralPath (Join-Path $OutputDirectory 'test-summary.json') -Encoding utf8
} else { throw "Unsupported test mode: $($configuration.testMode)" }
}
'Package' {
if (-not $configuration.packageBuilder) { throw 'This project has no package builder configured.' }
$builder = Resolve-RepositoryFile $configuration.packageBuilder
$destination = Join-Path $OutputDirectory 'package'
if (Test-Path -LiteralPath $destination) { throw 'The package output already exists; it will not be overwritten.' }
& {
Set-StrictMode -Off
& $builder -Destination $destination
}
$entry = Join-Path $destination $configuration.packageEntry
if (-not (Test-Path -LiteralPath $entry -PathType Leaf)) {
throw "The existing package builder did not produce the expected entry point: $entry"
}
$files = @(Get-ChildItem -LiteralPath $destination -Recurse -File)
if ($files.Count -eq 0) { throw 'The built package is empty.' }
$inventory = @(foreach ($file in $files) {
[pscustomobject]@{
File = $file.FullName.Substring($destination.Length + 1)
Bytes = $file.Length
SHA256 = (Get-FileHash -LiteralPath $file.FullName -Algorithm SHA256).Hash.ToLowerInvariant()
}
})
ConvertTo-Json -InputObject $inventory -Depth 5 |
Out-File -LiteralPath (Join-Path $OutputDirectory 'package-inventory.json') -Encoding utf8
Write-Output ('Existing package builder completed: {0} files.' -f $files.Count)
}
}
187 changes: 164 additions & 23 deletions .github/workflows/attest-release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -6,42 +6,183 @@ on:
workflow_dispatch:
inputs:
tag:
description: Release tag to attest; leave empty to use the current ref
required: false
description: Existing published release tag to attest (default branch only)
required: true
type: string

permissions:
contents: read
id-token: write
attestations: write

concurrency:
group: attest-release-${{ github.repository }}-${{ github.event.release.tag_name || inputs.tag }}
cancel-in-progress: false

jobs:
attest:
if: >-
github.repository_owner == 'Nico77600' &&
(
(github.event_name == 'release' && github.event.action == 'published' && github.event.release.draft == false) ||
(github.event_name == 'workflow_dispatch' && github.ref == format('refs/heads/{0}', github.event.repository.default_branch))
)
runs-on: ubuntu-latest
timeout-minutes: 20
permissions:
contents: read
id-token: write
attestations: write
steps:
- name: Check out source
uses: actions/checkout@v4
with:
ref: ${{ github.event.release.tag_name || inputs.tag || github.ref }}

- name: Collect release assets
- name: Download and verify distributed ZIP assets
shell: bash
env:
GH_TOKEN: ${{ github.token }}
RELEASE_REF: ${{ github.event.release.tag_name || inputs.tag || github.ref_name }}
GH_HOST: github.com
RELEASE_TAG: ${{ github.event.release.tag_name || inputs.tag }}
EXPECTED_RELEASE_ID: ${{ github.event.release.id }}
EVENT_ACTION: ${{ github.event.action }}
DEFAULT_BRANCH: ${{ github.event.repository.default_branch }}
run: |
set -euo pipefail
rm -rf dist
mkdir dist
release_tag="${RELEASE_REF##*/}"
if gh release view "$release_tag" --repo "$GITHUB_REPOSITORY" >/dev/null 2>&1; then
gh release download "$release_tag" --repo "$GITHUB_REPOSITORY" --dir dist
fi
if ! find dist -type f -print -quit | grep -q .; then
archive="dist/${GITHUB_REPOSITORY##*/}-${release_tag}.tar.gz"
tar --exclude=.git --exclude=./dist -czf "$archive" .
fi
python3 - <<'PY'
import hashlib
import json
import os
from pathlib import Path
import re
import subprocess
from urllib.parse import quote
import zipfile

def fail(message):
raise SystemExit(message)

def positive_integer(value, label):
if type(value) is not int or value <= 0:
fail(f"Invalid {label}")
return value

repository = os.environ["GITHUB_REPOSITORY"]
if not re.fullmatch(r"Nico77600/[A-Za-z0-9_.-]+", repository):
fail("Unexpected repository owner or name")
event = os.environ["GITHUB_EVENT_NAME"]
if event == "workflow_dispatch":
branch = os.environ["DEFAULT_BRANCH"]
if not branch or os.environ["GITHUB_REF"] != f"refs/heads/{branch}":
fail("Manual attestation must run from the default branch")
elif event == "release":
if os.environ["EVENT_ACTION"] != "published":
fail("Only published release events are trusted")
else:
fail("Unsupported event")
tag = os.environ.get("RELEASE_TAG", "")
if not tag:
fail("An explicit release tag is required")
subprocess.run(["git", "check-ref-format", f"refs/tags/{tag}"], check=True)
encoded_tag = quote(tag, safe="")
base = f"repos/{repository}"

def api(endpoint, *, paginate=False, output=None):
command = [
"gh", "api", "--hostname", "github.com", "--method", "GET",
"-H", "X-GitHub-Api-Version: 2022-11-28",
"-H", "Accept: application/octet-stream" if output is not None
else "Accept: application/vnd.github+json",
]
if paginate:
command.extend(["--paginate", "--slurp"])
command.append(endpoint)
# gh/Go strip Authorization on cross-host redirects; do not add a token header.
result = subprocess.run(
command, check=True, stdout=output if output is not None else subprocess.PIPE
)
if output is None:
return json.loads(result.stdout)

reference = api(f"{base}/git/ref/tags/{encoded_tag}")
if not isinstance(reference, dict) or reference.get("ref") != f"refs/tags/{tag}":
fail("The exact release tag does not exist")

def release():
metadata = api(f"{base}/releases/tags/{encoded_tag}")
if not isinstance(metadata, dict) or metadata.get("tag_name") != tag:
fail("Release tag does not match the requested tag")
if metadata.get("draft") is not False or not metadata.get("published_at"):
fail("The release must be published and not a draft")
release_id = positive_integer(metadata.get("id"), "release ID")
if event == "release" and str(release_id) != os.environ["EXPECTED_RELEASE_ID"]:
fail("Release ID does not match the published event")
return release_id

def zip_assets(release_id):
pages = api(f"{base}/releases/{release_id}/assets?per_page=100", paginate=True)
if not isinstance(pages, list) or any(not isinstance(page, list) for page in pages):
fail("Invalid paginated release asset response")
assets = [asset for page in pages for asset in page]
if not assets:
fail("The release has no distributed assets; no source archive fallback")
selected = {}
seen = set()
for asset in assets:
if not isinstance(asset, dict) or not isinstance(asset.get("name"), str):
fail("Invalid release asset metadata")
asset_id = positive_integer(asset.get("id"), "asset ID")
if asset_id in seen:
fail("Duplicate release asset ID")
seen.add(asset_id)
if not asset["name"].lower().endswith(".zip"):
continue
if asset.get("state") != "uploaded":
fail("A ZIP asset is not fully uploaded")
size = positive_integer(asset.get("size"), "ZIP asset size")
digest = asset.get("digest")
if digest is not None and (
not isinstance(digest, str) or not re.fullmatch(r"sha256:[0-9a-fA-F]{64}", digest)
):
fail("Invalid or unsupported ZIP asset digest")
selected[asset_id] = (asset["name"], size, digest.lower() if digest else None)
if not selected:
fail("The release has no distributed ZIP assets")
if len(selected) > 1024:
fail("The release exceeds the attestation action's 1024-subject limit")
return selected

release_id = release()
assets = zip_assets(release_id)
# A fresh directory and numeric filenames exclude stale files and name/path injection.
Path("dist").mkdir()
for asset_id, (name, expected_size, expected_digest) in sorted(assets.items()):
target = Path("dist") / f"{asset_id}.zip"
try:
with target.open("xb") as output:
api(f"{base}/releases/assets/{asset_id}", output=output)
if target.stat().st_size != expected_size:
fail(f"Size mismatch for ZIP asset {asset_id}")
hasher = hashlib.sha256()
with target.open("rb") as stream:
for chunk in iter(lambda: stream.read(1024 * 1024), b""):
hasher.update(chunk)
stream.seek(0)
if not zipfile.is_zipfile(stream):
fail(f"Asset {asset_id} is not a ZIP file")
digest = f"sha256:{hasher.hexdigest()}"
if expected_digest is not None and digest != expected_digest:
fail(f"SHA-256 mismatch for ZIP asset {asset_id}")
print(json.dumps({
"asset_id": asset_id, "release_name": name, "subject": str(target),
"size": expected_size, "digest": digest,
"github_digest_verified": expected_digest is not None,
}, ensure_ascii=True))
except BaseException:
target.unlink(missing_ok=True)
raise
if release() != release_id or zip_assets(release_id) != assets:
fail("Release ZIP assets changed during download; retry with a stable release")
PY

# Binds distributed ZIP bytes to this repository/workflow; does not prove their original build.
- name: Generate artifact attestation
uses: actions/attest@v4
uses: actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6 # v4
with:
subject-path: 'dist/*'
subject-path: 'dist/*.zip'
push-to-registry: false
create-storage-record: false
Loading
Loading