From 4cefc854c43614ce53d4b9efb34229bfd1e03342 Mon Sep 17 00:00:00 2001 From: Richard McDaniel <1130888+rmcdaniel@users.noreply.github.com> Date: Tue, 3 Nov 2026 22:33:56 -0600 Subject: [PATCH 1/2] Serve the CLI repository's current installers and pin them durable-workflow.com/install.sh and /install.ps1 were copies of the CLI installers last synced in August. They verified attestations without the CLI's signer pin (release or release-rust workflow on main or a release tag) and without --deny-self-hosted-runners. Replace both byte for byte with durable-workflow/cli 3df282fa scripts/install.sh and scripts/install.ps1, record that source with sha256 values, and fail the build when either file drifts. A new workflow installs the stable CLI end to end with signer-pinned attestations on Linux, macOS and Windows, and checks that an installer trusting another workflow refuses the release. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/installers.yml | 99 ++++++++++++++++++++++++++ package.json | 2 +- scripts/check-installer-source.test.js | 31 ++++++++ scripts/installer-source.json | 14 ++++ static/install.ps1 | 23 +++--- static/install.sh | 31 ++++---- 6 files changed, 174 insertions(+), 26 deletions(-) create mode 100644 .github/workflows/installers.yml create mode 100644 scripts/check-installer-source.test.js create mode 100644 scripts/installer-source.json diff --git a/.github/workflows/installers.yml b/.github/workflows/installers.yml new file mode 100644 index 00000000000..e262b50273e --- /dev/null +++ b/.github/workflows/installers.yml @@ -0,0 +1,99 @@ +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 + attestations: 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 + 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: | + sh static/install.sh + "$DURABLE_WORKFLOW_INSTALL_DIR/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" + if sh "$RUNNER_TEMP/install-wrong-signer.sh"; then + echo 'installer accepted a release signed by an unexpected workflow' >&2 + exit 1 + fi + test ! -e "$DURABLE_WORKFLOW_INSTALL_DIR/dw" + + powershell-installer: + name: install.ps1 (windows-latest) + runs-on: windows-latest + timeout-minutes: 10 + 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 { $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' } 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" From 565afc0a5b4294bf3b443394653879d1bda1f6bd Mon Sep 17 00:00:00 2001 From: Richard McDaniel <1130888+rmcdaniel@users.noreply.github.com> Date: Tue, 3 Nov 2026 22:42:27 -0600 Subject: [PATCH 2/2] Make the installer checks pass for the right reasons Put the install directory on PATH so the installer's not-ready exit does not fail a good install, require the negative case to fail at the attestation step, reset PowerShell's exit code after the expected gh refusal, and grant attestations: read at job level (Sonar S8264). The installers and their recorded source are unchanged. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/installers.yml | 27 +++++++++++++++++++++++---- 1 file changed, 23 insertions(+), 4 deletions(-) diff --git a/.github/workflows/installers.yml b/.github/workflows/installers.yml index e262b50273e..c323d855c53 100644 --- a/.github/workflows/installers.yml +++ b/.github/workflows/installers.yml @@ -12,7 +12,6 @@ on: permissions: contents: read - attestations: read concurrency: group: cli-installers-${{ github.ref }} @@ -31,6 +30,9 @@ jobs: 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 @@ -43,8 +45,10 @@ jobs: 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 - "$DURABLE_WORKFLOW_INSTALL_DIR/dw" --version | tee "$RUNNER_TEMP/version.txt" + dw --version | tee "$RUNNER_TEMP/version.txt" grep -F "$CLI_VERSION" "$RUNNER_TEMP/version.txt" - name: Refuse a release signed by another workflow env: @@ -56,16 +60,25 @@ jobs: # 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" - if sh "$RUNNER_TEMP/install-wrong-signer.sh"; then + 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 @@ -94,6 +107,12 @@ jobs: $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 { $refused = $true; Write-Host "Refused as expected: $_" } + 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