fix(release): fetch public Stable manifest with updater Go client (#10706)

Problem: v1.38.12 candidate preparation is blocked by Cloudflare challenges against curl, even with the updater User-Agent.

Root cause: Cloudflare scores the transport as well as the header; protected Actions probes repeatedly returned HTTP 200 for the shipped Go updater client and 403 for curl/Node profiles.

Fix: Reuse the Go updater HTTP request identity for every Stable manifest gate and postflight, with explicit Go setup, bounded response size, HTTP/challenge rejection, and JSON version validation.

Verification: three protected Go probes returned HTTP 200 for v1.38.12; focused Go and shell fixtures, release control contracts, release workflow contracts, and local public preflight pass.
This commit is contained in:
SivanCola authored and GitHub committed 2026-09-23 19:53:50 +00:00
1 parent 8ea7439cb6
commit d728bdab0e
12 files changed
+167 -26

No files matched your search

+5
View File
@@ -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:
+4
View File
@@ -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:
+8
View File
@@ -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 }}
@@ -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 }}
+4
View File
@@ -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 }}
+4
View File
@@ -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 }}
+4 -20
View File
@@ -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"
+74
View File
@@ -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)
}
}
@@ -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: "<html>challenge</html>", status: http.StatusForbidden, mitigation: "challenge", wantError: "HTTP 403"},
{name: "malformed JSON", body: "<html>challenge</html>", 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)
}
})
}
}
+1 -1
View File
@@ -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],
+6 -5
View File
@@ -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');
@@ -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