diff --git a/.github/workflows/installers.yml b/.github/workflows/installers.yml new file mode 100644 index 00000000000..c323d855c53 --- /dev/null +++ b/.github/workflows/installers.yml @@ -0,0 +1,118 @@ +name: CLI installers + +on: + pull_request: + paths: + - static/install.sh + - static/install.ps1 + - scripts/installer-source.json + - scripts/check-installer-source.test.js + - .github/workflows/installers.yml + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: cli-installers-${{ github.ref }} + cancel-in-progress: true + +env: + # The published stable CLI these runs install end to end. + CLI_VERSION: 2.3.5 + +jobs: + shell-installer: + name: install.sh (${{ matrix.os }}) + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest] + runs-on: ${{ matrix.os }} + timeout-minutes: 10 + permissions: + contents: read + attestations: read + steps: + - name: Check out source + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 + with: + persist-credentials: false + - name: Install the stable CLI with signer-pinned attestations + env: + GH_TOKEN: ${{ github.token }} + VERSION: ${{ env.CLI_VERSION }} + DURABLE_WORKFLOW_INSTALL_DIR: ${{ runner.temp }}/dw-bin + DURABLE_WORKFLOW_INSTALL_VERIFY_ATTESTATIONS: '1' + run: | + # The installer reports "not ready" (exit 1) unless dw resolves on PATH. + export PATH="$DURABLE_WORKFLOW_INSTALL_DIR:$PATH" + sh static/install.sh + dw --version | tee "$RUNNER_TEMP/version.txt" + grep -F "$CLI_VERSION" "$RUNNER_TEMP/version.txt" + - name: Refuse a release signed by another workflow + env: + GH_TOKEN: ${{ github.token }} + VERSION: ${{ env.CLI_VERSION }} + DURABLE_WORKFLOW_INSTALL_DIR: ${{ runner.temp }}/dw-refused + DURABLE_WORKFLOW_INSTALL_VERIFY_ATTESTATIONS: '1' + run: | + # Same installer, but it only trusts a workflow that never signed the release. + sed 's/(release|release-rust)/(not-release)/' static/install.sh > "$RUNNER_TEMP/install-wrong-signer.sh" + ! cmp -s static/install.sh "$RUNNER_TEMP/install-wrong-signer.sh" + export PATH="$DURABLE_WORKFLOW_INSTALL_DIR:$PATH" + if sh "$RUNNER_TEMP/install-wrong-signer.sh" > "$RUNNER_TEMP/refused.log" 2>&1; then + cat "$RUNNER_TEMP/refused.log" + echo 'installer accepted a release signed by an unexpected workflow' >&2 + exit 1 + fi + cat "$RUNNER_TEMP/refused.log" + # Refused by the attestation check, not by an earlier step. + grep -F 'Verifying GitHub artifact attestations' "$RUNNER_TEMP/refused.log" + grep -F 'verifying with issuer' "$RUNNER_TEMP/refused.log" + test ! -e "$DURABLE_WORKFLOW_INSTALL_DIR/dw" + + powershell-installer: + name: install.ps1 (windows-latest) + runs-on: windows-latest + timeout-minutes: 10 + permissions: + contents: read + attestations: read + steps: + - name: Check out source + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 + with: + persist-credentials: false + - name: Install the stable CLI with signer-pinned attestations + shell: pwsh + env: + GH_TOKEN: ${{ github.token }} + VERSION: ${{ env.CLI_VERSION }} + DURABLE_WORKFLOW_INSTALL_VERIFY_ATTESTATIONS: '1' + run: | + $env:DURABLE_WORKFLOW_INSTALL_DIR = Join-Path $env:RUNNER_TEMP 'dw-bin' + & ./static/install.ps1 + $version = & (Join-Path $env:DURABLE_WORKFLOW_INSTALL_DIR 'dw.exe') --version | Out-String + Write-Host $version + if (-not $version.Contains($env:CLI_VERSION)) { throw "Installed dw is not $env:CLI_VERSION" } + - name: Refuse a release signed by another workflow + shell: pwsh + env: + GH_TOKEN: ${{ github.token }} + VERSION: ${{ env.CLI_VERSION }} + DURABLE_WORKFLOW_INSTALL_VERIFY_ATTESTATIONS: '1' + run: | + $env:DURABLE_WORKFLOW_INSTALL_DIR = Join-Path $env:RUNNER_TEMP 'dw-refused' + $wrong = Join-Path $env:RUNNER_TEMP 'install-wrong-signer.ps1' + (Get-Content -Raw ./static/install.ps1).Replace('(release|release-rust)', '(not-release)') | Set-Content -NoNewline $wrong + $refused = $false + try { & $wrong } catch { + if ("$_" -notlike 'Attestation verification failed*') { throw } + $refused = $true + Write-Host "Refused as expected: $_" + } + if (-not $refused) { throw 'installer accepted a release signed by an unexpected workflow' } + if (Test-Path (Join-Path $env:DURABLE_WORKFLOW_INSTALL_DIR 'dw.exe')) { throw 'refused install left dw.exe behind' } + # The expected gh failure leaves a nonzero exit code behind. + $global:LASTEXITCODE = 0 diff --git a/package.json b/package.json index 802ba6bb365..4205425c666 100644 --- a/package.json +++ b/package.json @@ -12,7 +12,7 @@ "postinstall": "patch-package", "prestart": "node scripts/check-dependency-security.js", "start": "docusaurus start", - "build": "npm run check:dependency-security && npm run check:node-toolchain && node scripts/check-helm-chart-pairing.test.js && node scripts/check-install-sdk.test.js && node scripts/check-server-config-reference.test.js && node scripts/check-server-config-reference.js && node scripts/check-local-search.test.js && node scripts/check-doc-translation-updates.test.js && node scripts/check-doc-translations.js && node scripts/check-localized-links.test.js && docusaurus build && node scripts/generate-llms.js && node scripts/generate-llms-full.js", + "build": "npm run check:dependency-security && npm run check:node-toolchain && node scripts/check-helm-chart-pairing.test.js && node scripts/check-install-sdk.test.js && node scripts/check-installer-source.test.js && node scripts/check-server-config-reference.test.js && node scripts/check-server-config-reference.js && node scripts/check-local-search.test.js && node scripts/check-doc-translation-updates.test.js && node scripts/check-doc-translations.js && node scripts/check-localized-links.test.js && docusaurus build && node scripts/generate-llms.js && node scripts/generate-llms-full.js", "check:node-toolchain": "node scripts/check-node-toolchain.js && node scripts/check-node-toolchain.test.js", "check:dependency-security": "node scripts/check-dependency-security.js && node scripts/check-dependency-security.test.js && node scripts/check-open-browser.test.js", "check:platform-protocol-specs": "node scripts/check-platform-protocol-specs.js", diff --git a/scripts/check-installer-source.test.js b/scripts/check-installer-source.test.js new file mode 100644 index 00000000000..da3a111102d --- /dev/null +++ b/scripts/check-installer-source.test.js @@ -0,0 +1,31 @@ +const assert = require('node:assert/strict'); +const crypto = require('node:crypto'); +const fs = require('node:fs'); +const path = require('node:path'); +const test = require('node:test'); + +// The site serves durable-workflow.com/install.sh and /install.ps1. They must +// stay byte-identical to the CLI repository's installers at the recorded +// source commit, so a site copy cannot silently drift from the reviewed CLI +// installer (for example, losing its attestation signer pin). +const root = path.resolve(__dirname, '..'); +const source = JSON.parse(fs.readFileSync(path.join(__dirname, 'installer-source.json'), 'utf8')); + +test('the installer source names a CLI commit', () => { + assert.equal(source.source_repository, 'durable-workflow/cli'); + assert.match(source.source_commit, /^[0-9a-f]{40}$/); +}); + +for (const [file, entry] of Object.entries(source.files)) { + test(`${file} matches ${source.source_repository}@${source.source_commit.slice(0, 8)}:${entry.source_path}`, () => { + const bytes = fs.readFileSync(path.join(root, file)); + const actual = crypto.createHash('sha256').update(bytes).digest('hex'); + assert.equal(actual, entry.sha256, + `${file} differs from the recorded CLI installer. Copy ${entry.source_path} from ` + + `${source.source_repository} byte for byte and update scripts/installer-source.json.`); + }); +} + +test('both public installers are pinned', () => { + assert.deepEqual(Object.keys(source.files).sort(), ['static/install.ps1', 'static/install.sh']); +}); diff --git a/scripts/installer-source.json b/scripts/installer-source.json new file mode 100644 index 00000000000..8dd75872173 --- /dev/null +++ b/scripts/installer-source.json @@ -0,0 +1,14 @@ +{ + "source_repository": "durable-workflow/cli", + "source_commit": "3df282fa33ebe5e3319e68298aeef2c32d64c1af", + "files": { + "static/install.sh": { + "source_path": "scripts/install.sh", + "sha256": "5f17db134cd04cc455de1908df0d0d80d2e8e7de0d89731b58689fbb70313b18" + }, + "static/install.ps1": { + "source_path": "scripts/install.ps1", + "sha256": "52d3ef117eaa360527924a54401cb01d1d4aedaa5ca19c28846148eb39dffd0b" + } + } +} diff --git a/static/install.ps1 b/static/install.ps1 index c158a2f9269..33d6084aca2 100644 --- a/static/install.ps1 +++ b/static/install.ps1 @@ -4,7 +4,7 @@ # irm https://durable-workflow.com/install.ps1 | iex # # Environment variables: -# $env:VERSION Release tag, supported, prerelease, or stable (default: stable). +# $env:VERSION Release tag or stable/latest (default: stable). # $env:DURABLE_WORKFLOW_INSTALL_DIR Install directory (default: %USERPROFILE%\.durable-workflow\bin). # $env:DURABLE_WORKFLOW_RELEASE_BASE_URL Release base URL override for tests. # $env:DURABLE_WORKFLOW_INSTALL_VERIFY_ATTESTATIONS @@ -25,13 +25,8 @@ $installDir = if ($env:DURABLE_WORKFLOW_INSTALL_DIR) { Join-Path $env:USERPROFILE '.durable-workflow\bin' } $version = if ($env:VERSION) { $env:VERSION } else { 'stable' } -$version = if ($version -eq 'supported') { - 'stable' -} elseif ($version -eq 'prerelease') { - throw 'Prerelease auto-selection ended with the stable 2.0 launch; set VERSION to an exact historical tag.' -} else { - $version -} +if ($version -eq 'supported') { $version = 'stable' } +if ($version -eq 'prerelease') { throw 'Pass an explicit prerelease tag with VERSION=...' } $releaseVersion = if ($version.StartsWith('v')) { $version.Substring(1) } else { @@ -102,8 +97,16 @@ try { } Write-Host '==> Verifying GitHub artifact attestations' -ForegroundColor Green - gh attestation verify $tmp --repo $repo - gh attestation verify $sums --repo $repo + # Only the repository's release workflows, run from main or a release + # tag on GitHub-hosted runners, can have signed a release asset. + $repoPattern = [regex]::Escape($repo) + $signer = "^https://github\.com/$repoPattern/\.github/workflows/(release|release-rust)\.yml@refs/(heads/main|tags/v?[0-9]+\.[0-9]+\.[0-9]+([-+][0-9A-Za-z.+-]+)?)$" + foreach ($subject in @($tmp, $sums)) { + gh attestation verify $subject --repo $repo --cert-identity-regex $signer --deny-self-hosted-runners + if ($LASTEXITCODE -ne 0) { + throw "Attestation verification failed for $subject." + } + } } Move-Item -Force -Path $tmp -Destination $dest diff --git a/static/install.sh b/static/install.sh index 130ae1e052a..c926e75e3ef 100644 --- a/static/install.sh +++ b/static/install.sh @@ -5,7 +5,7 @@ # curl -fsSL https://durable-workflow.com/install.sh | sh # # Environment variables: -# VERSION Release tag, supported, prerelease, or stable (default: stable). +# VERSION Release tag or stable/latest (default: stable). # DURABLE_WORKFLOW_INSTALL_DIR Install directory (default: ~/.local/bin). # DURABLE_WORKFLOW_BIN_NAME Executable name (default: dw). # DURABLE_WORKFLOW_RELEASE_BASE_URL Release base URL override for tests. @@ -61,13 +61,10 @@ fi asset="dw-${os}-${arch}" command -v curl >/dev/null 2>&1 || err "curl is required" -if [ "$VERSION" = "supported" ]; then - VERSION="stable" -fi - -if [ "$VERSION" = "prerelease" ]; then - err "prerelease auto-selection ended with the stable 2.0 launch; set VERSION to an exact historical tag" -fi +case "$VERSION" in + supported) VERSION=stable ;; + prerelease) err "pass an explicit prerelease tag with VERSION=..." ;; +esac if [ "$VERSION" = "latest" ] || [ "$VERSION" = "stable" ]; then url="${RELEASE_BASE_URL}/latest/download/${asset}" @@ -130,13 +127,17 @@ if [ "$VERIFY_ATTESTATIONS" = "1" ]; then command -v gh >/dev/null 2>&1 || err "gh is required when DURABLE_WORKFLOW_INSTALL_VERIFY_ATTESTATIONS=1" info "Verifying GitHub artifact attestations" - if [ "$OUTPUT_MODE" = "json" ]; then - gh attestation verify "$tmp" --repo "$REPO" >&2 - gh attestation verify "$sums" --repo "$REPO" >&2 - else - gh attestation verify "$tmp" --repo "$REPO" - gh attestation verify "$sums" --repo "$REPO" - fi + # Only the repository's release workflows, run from main or a release + # tag on GitHub-hosted runners, can have signed a release asset. + repo_pattern=$(printf '%s' "$REPO" | sed 's/[.]/\\./g') + signer="^https://github\\.com/${repo_pattern}/\\.github/workflows/(release|release-rust)\\.yml@refs/(heads/main|tags/v?[0-9]+\\.[0-9]+\\.[0-9]+([-+][0-9A-Za-z.+-]+)?)\$" + for subject in "$tmp" "$sums"; do + if [ "$OUTPUT_MODE" = "json" ]; then + gh attestation verify "$subject" --repo "$REPO" --cert-identity-regex "$signer" --deny-self-hosted-runners >&2 + else + gh attestation verify "$subject" --repo "$REPO" --cert-identity-regex "$signer" --deny-self-hosted-runners + fi + done fi chmod +x "$tmp"