diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml index fca9c2e599..9e1b4a339f 100644 --- a/.github/workflows/pages.yml +++ b/.github/workflows/pages.yml @@ -65,6 +65,11 @@ jobs: if: inputs.release_version != '' with: node-version: "22" + - uses: actions/setup-go@v7 + if: inputs.release_version != '' + with: + go-version-file: go.mod + cache: false - name: Recheck release ownership immediately before deployment if: inputs.release_version != '' env: diff --git a/.github/workflows/release-candidate.yml b/.github/workflows/release-candidate.yml index f5a4a7b0cf..3a7d1a63e1 100644 --- a/.github/workflows/release-candidate.yml +++ b/.github/workflows/release-candidate.yml @@ -49,6 +49,10 @@ jobs: - uses: actions/setup-node@v7 with: node-version: "22" + - uses: actions/setup-go@v7 + with: + go-version-file: go.mod + cache: false - name: Resolve reviewed source id: candidate env: diff --git a/.github/workflows/release-promote.yml b/.github/workflows/release-promote.yml index 8fd731f259..b2fd2cfe19 100644 --- a/.github/workflows/release-promote.yml +++ b/.github/workflows/release-promote.yml @@ -52,6 +52,10 @@ jobs: - uses: actions/setup-node@v7 with: node-version: "22" + - uses: actions/setup-go@v7 + with: + go-version-file: go.mod + cache: false - name: Validate executable contracts and public access before payload or approval env: CANDIDATE_ID: ${{ inputs.candidate_id }} @@ -316,6 +320,10 @@ jobs: - uses: actions/setup-node@v7 with: node-version: "22" + - uses: actions/setup-go@v7 + with: + go-version-file: go.mod + cache: false - name: Reconcile public artifacts, owned Pages deployment, and durable ledger env: GH_TOKEN: ${{ github.token }} diff --git a/.github/workflows/release-site-recovery.yml b/.github/workflows/release-site-recovery.yml index a30d8f7f6f..96138de430 100644 --- a/.github/workflows/release-site-recovery.yml +++ b/.github/workflows/release-site-recovery.yml @@ -32,6 +32,10 @@ jobs: - uses: actions/setup-node@v7 with: node-version: "22" + - uses: actions/setup-go@v7 + with: + go-version-file: go.mod + cache: false - name: Validate executable contracts and public access before approval env: VERSION: ${{ inputs.version }} @@ -83,6 +87,10 @@ jobs: - uses: actions/setup-node@v7 with: node-version: "22" + - uses: actions/setup-go@v7 + with: + go-version-file: go.mod + cache: false - name: Recover only the website and verify public state again env: GH_TOKEN: ${{ github.token }} diff --git a/.github/workflows/release-stable.yml b/.github/workflows/release-stable.yml index d4743b1dd1..e518d12a7f 100644 --- a/.github/workflows/release-stable.yml +++ b/.github/workflows/release-stable.yml @@ -261,6 +261,10 @@ jobs: - uses: actions/setup-node@v7 with: node-version: "22" + - uses: actions/setup-go@v7 + with: + go-version-file: go.mod + cache: false - name: Verify public artifacts and npm latest env: GH_TOKEN: ${{ github.token }} diff --git a/.github/workflows/release-verify.yml b/.github/workflows/release-verify.yml index c367ecbcd3..25290432de 100644 --- a/.github/workflows/release-verify.yml +++ b/.github/workflows/release-verify.yml @@ -23,6 +23,10 @@ jobs: - uses: actions/setup-node@v7 with: node-version: "22" + - uses: actions/setup-go@v7 + with: + go-version-file: go.mod + cache: false - name: Verify public release env: GH_TOKEN: ${{ github.token }} diff --git a/scripts/fetch-stable-release-manifest.sh b/scripts/fetch-stable-release-manifest.sh index 011fe3b01d..e9c053b08d 100644 --- a/scripts/fetch-stable-release-manifest.sh +++ b/scripts/fetch-stable-release-manifest.sh @@ -5,23 +5,7 @@ test "$#" -eq 2 || { echo 'usage: fetch-stable-release-manifest.sh VERSION OUTPU version="$1" [[ "$version" =~ ^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$ ]] || { echo 'Stable manifest probe requires a stable version' >&2; exit 2; } output="$2" -headers="$(mktemp)" -trap 'rm -f -- "$headers"' EXIT -# Probe with the exact request identity used by the Linux Stable updater. The -# default curl/Node identity receives a Cloudflare challenge on Actions runners. -user_agent="Reasonix-Updater/v$version (linux/amd64; build=stable; update=stable)" -if curl -fsSL --connect-timeout 15 --max-time 45 -A "$user_agent" -D "$headers" https://dl.reasonix.io/latest/latest.json > "$output"; then - : -else - status=$? - echo "Stable manifest observation failed: https://dl.reasonix.io/latest/latest.json (curl exit $status)" >&2 - awk 'tolower($0) ~ /^(http\/|server:|cf-ray:|cf-mitigated:|retry-after:)/ { print }' "$headers" >&2 - exit "$status" -fi -node - "$output" <<'JS' -const fs = require('node:fs'); -const manifest = JSON.parse(fs.readFileSync(process.argv[2], 'utf8')); -if (!/^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$/.test(manifest?.version || '')) { - throw new Error('Public Stable manifest has no valid stable version'); -} -JS +script_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" +# Use the shipped updater's Go HTTP stack. On Actions runners, curl and Node +# can receive a Cloudflare challenge for the same public URL and User-Agent. +go run "$script_dir/release-manifest-fetch/main.go" "$version" "$output" diff --git a/scripts/release-manifest-fetch/main.go b/scripts/release-manifest-fetch/main.go new file mode 100644 index 0000000000..8bb8e1d536 --- /dev/null +++ b/scripts/release-manifest-fetch/main.go @@ -0,0 +1,74 @@ +package main + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "os" + "regexp" + "runtime" + "strings" + "time" +) + +const manifestURL = "https://dl.reasonix.io/latest/latest.json" +const maxManifestBytes = 1 << 20 + +var stableVersion = regexp.MustCompile(`^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$`) +var stableManifestVersion = regexp.MustCompile(`^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$`) + +func updaterUserAgent(version string) string { + return fmt.Sprintf("Reasonix-Updater/v%s (%s/%s; build=stable; update=stable)", version, runtime.GOOS, runtime.GOARCH) +} + +func fetchManifest(client *http.Client, endpoint, version string) ([]byte, error) { + request, err := http.NewRequest(http.MethodGet, endpoint, nil) + if err != nil { + return nil, err + } + request.Header.Set("User-Agent", updaterUserAgent(version)) + response, err := client.Do(request) + if err != nil { + return nil, err + } + defer response.Body.Close() + if response.StatusCode != http.StatusOK || response.Header.Get("cf-mitigated") != "" { + return nil, fmt.Errorf("public Stable manifest access failed: HTTP %d, cf-mitigated=%q, cf-ray=%q", + response.StatusCode, response.Header.Get("cf-mitigated"), response.Header.Get("cf-ray")) + } + body, err := io.ReadAll(io.LimitReader(response.Body, maxManifestBytes+1)) + if err != nil { + return nil, err + } + if len(body) > maxManifestBytes { + return nil, fmt.Errorf("public Stable manifest exceeds %d bytes", maxManifestBytes) + } + var manifest struct { + Version string `json:"version"` + } + if err := json.Unmarshal(body, &manifest); err != nil { + return nil, fmt.Errorf("public Stable manifest is not JSON: %w", err) + } + if !stableManifestVersion.MatchString(manifest.Version) { + return nil, fmt.Errorf("public Stable manifest has no valid Stable version") + } + return body, nil +} + +func main() { + if len(os.Args) != 3 || !stableVersion.MatchString(os.Args[1]) || strings.TrimSpace(os.Args[2]) == "" { + fmt.Fprintln(os.Stderr, "usage: release-manifest-fetch VERSION OUTPUT") + os.Exit(2) + } + client := &http.Client{Timeout: 45 * time.Second} + body, err := fetchManifest(client, manifestURL, os.Args[1]) + if err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } + if err := os.WriteFile(os.Args[2], body, 0o600); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } +} diff --git a/scripts/release-manifest-fetch/main_test.go b/scripts/release-manifest-fetch/main_test.go new file mode 100644 index 0000000000..23efc4ad13 --- /dev/null +++ b/scripts/release-manifest-fetch/main_test.go @@ -0,0 +1,48 @@ +package main + +import ( + "net/http" + "net/http/httptest" + "runtime" + "strings" + "testing" +) + +func TestFetchManifestUsesUpdaterClientAndRejectsChallenges(t *testing.T) { + for _, test := range []struct { + name, body, mitigation string + status int + wantError string + }{ + {name: "public manifest", body: `{"version":"v1.38.11"}`, status: http.StatusOK}, + {name: "challenge", body: "challenge", status: http.StatusForbidden, mitigation: "challenge", wantError: "HTTP 403"}, + {name: "malformed JSON", body: "challenge", status: http.StatusOK, wantError: "not JSON"}, + {name: "invalid version", body: `{"version":"preview"}`, status: http.StatusOK, wantError: "valid Stable version"}, + } { + t.Run(test.name, func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { + wantAgent := "Reasonix-Updater/v1.38.12 (" + runtime.GOOS + "/" + runtime.GOARCH + "; build=stable; update=stable)" + if got := request.Header.Get("User-Agent"); got != wantAgent { + t.Errorf("User-Agent = %q, want %q", got, wantAgent) + } + writer.Header().Set("cf-mitigated", test.mitigation) + writer.WriteHeader(test.status) + _, _ = writer.Write([]byte(test.body)) + })) + defer server.Close() + body, err := fetchManifest(server.Client(), server.URL, "1.38.12") + if test.wantError != "" { + if err == nil || !strings.Contains(err.Error(), test.wantError) { + t.Fatalf("error = %v, want %q", err, test.wantError) + } + if len(body) != 0 { + t.Fatalf("failed response returned %d bytes", len(body)) + } + return + } + if err != nil || string(body) != test.body { + t.Fatalf("body = %q, error = %v", body, err) + } + }) + } +} diff --git a/scripts/release-publication-ledger.test.mjs b/scripts/release-publication-ledger.test.mjs index 84f0ac02c0..4735fc7f44 100644 --- a/scripts/release-publication-ledger.test.mjs +++ b/scripts/release-publication-ledger.test.mjs @@ -67,7 +67,7 @@ test("site ownership requires an exact release or a proven newer recovery pointe test("actual site observation fails closed on HTTP errors and malformed or stale responses", () => { const directory = mkdtempSync(join(tmpdir(), "release-site-observation-")); try { - writeFileSync(join(directory, "curl"), '#!/bin/sh\nprintf "%s" "$RESPONSE_BODY"\nexit "$RESPONSE_STATUS"\n', { mode: 0o755 }); + writeFileSync(join(directory, "go"), '#!/bin/sh\nif [ "$RESPONSE_STATUS" != 0 ]; then exit "$RESPONSE_STATUS"; fi\nprintf "%s" "$RESPONSE_BODY" > "$4"\n', { mode: 0o755 }); const helper = fileURLToPath(new URL("./observe-release-site.sh", import.meta.url)); for (const [status, body, expected] of [ [22, "", null], [22, '{"version":"v1.3.0"}', null], diff --git a/scripts/sync-release-site.test.mjs b/scripts/sync-release-site.test.mjs index 9ee9fa8549..b1a762b3e7 100644 --- a/scripts/sync-release-site.test.mjs +++ b/scripts/sync-release-site.test.mjs @@ -27,13 +27,14 @@ else fi `); writeFileSync(path.join(scripts, "release-event.mjs"), `import {writeFileSync} from 'node:fs'; writeFileSync(process.argv[process.argv.indexOf('--output')+1], '{}');`); - writeFileSync(path.join(bin, "curl"), `#!/usr/bin/env node + writeFileSync(path.join(bin, "go"), `#!/usr/bin/env node const args=process.argv.slice(2); -if (!args.includes('--connect-timeout') || !args.includes('--max-time') || - args[args.indexOf('-A')+1] !== 'Reasonix-Updater/v1.2.3 (linux/amd64; build=stable; update=stable)' || - args.at(-1)!=='https://dl.reasonix.io/latest/latest.json') process.exit(99); +if (args[0]!=='run' || !args[1].endsWith('/release-manifest-fetch/main.go') || args[2]!=='1.2.3') process.exit(99); if(process.env.HTTP_FAIL==='true') process.exit(22); -process.stdout.write(process.env.MANIFEST_BODY || JSON.stringify({version:process.env.POINTER || 'v1.2.3'})); +const body=process.env.MANIFEST_BODY || JSON.stringify({version:process.env.POINTER || 'v1.2.3'}); +try { if(!/^v[0-9]+\\.[0-9]+\\.[0-9]+$/.test(JSON.parse(body).version)) process.exit(1); } +catch { process.exit(1); } +require('node:fs').writeFileSync(args[3],body); `, { mode: 0o755 }); writeFileSync(path.join(bin, "gh"), `#!/usr/bin/env node const fs=require('node:fs'), path=require('node:path'); diff --git a/scripts/validate-release-control-plane.sh b/scripts/validate-release-control-plane.sh index c08d8532c6..45c61a4647 100755 --- a/scripts/validate-release-control-plane.sh +++ b/scripts/validate-release-control-plane.sh @@ -24,6 +24,7 @@ required=( scripts/release-publication-ledger.mjs scripts/check-release-public-access.sh scripts/fetch-stable-release-manifest.sh + scripts/release-manifest-fetch/main.go scripts/resolve-release-candidate.mjs scripts/sign-certum.ps1 scripts/test-windows-installer-startup.ps1