diff --git a/.github/actions/setup-integration-test-env/action.yml b/.github/actions/setup-integration-test-env/action.yml
index ee71d7b708..c4373614a1 100644
--- a/.github/actions/setup-integration-test-env/action.yml
+++ b/.github/actions/setup-integration-test-env/action.yml
@@ -3,7 +3,7 @@ description: Prepare toolchains, Viceroy, and Docker images for integration test
inputs:
origin-port:
- description: Fixed origin port baked into the WASM binary for integration tests.
+ description: Fixed origin port used by integration runtime configuration and container mappings.
required: true
install-viceroy:
description: Install the Viceroy binary used by integration tests.
diff --git a/.github/workflows/format.yml b/.github/workflows/format.yml
index f458572eb7..42e7ed4350 100644
--- a/.github/workflows/format.yml
+++ b/.github/workflows/format.yml
@@ -1,4 +1,4 @@
-name: "Run Format"
+name: 'Run Format'
on:
push:
@@ -61,7 +61,7 @@ jobs:
uses: ./.github/actions/setup-rust
with:
target: wasm32-wasip1,wasm32-unknown-unknown
- components: "clippy, rustfmt"
+ components: 'clippy, rustfmt'
cache-key: cargo-clippy
- name: Run cargo fmt
@@ -113,14 +113,14 @@ jobs:
- name: Retrieve Node.js version
id: node-version
working-directory: .
- run: echo "node-version=$(grep '^nodejs ' .tool-versions | awk '{print $2}')" >> $GITHUB_OUTPUT
+ run: echo "node-version=$(grep '^nodejs ' .tool-versions | awk '{print $2}')" >> "$GITHUB_OUTPUT"
shell: bash
- name: Use Node.js
uses: actions/setup-node@v4
with:
node-version: ${{ steps.node-version.outputs.node-version }}
- cache: "npm"
+ cache: 'npm'
cache-dependency-path: crates/trusted-server-js/lib/package.json
- name: Install dependencies
@@ -144,14 +144,14 @@ jobs:
- name: Retrieve Node.js version
id: node-version
working-directory: .
- run: echo "node-version=$(grep '^nodejs ' .tool-versions | awk '{print $2}')" >> $GITHUB_OUTPUT
+ run: echo "node-version=$(grep '^nodejs ' .tool-versions | awk '{print $2}')" >> "$GITHUB_OUTPUT"
shell: bash
- name: Use Node.js
uses: actions/setup-node@v4
with:
node-version: ${{ steps.node-version.outputs.node-version }}
- cache: "npm"
+ cache: 'npm'
cache-dependency-path: docs/package.json
- name: Install dependencies
@@ -167,7 +167,7 @@ jobs:
working-directory: .
run: >-
docs/node_modules/.bin/prettier --config docs/.prettierrc --check
- "*.md" ".claude/**/*.md" ".github/**/*.md" "crates/**/*.md" "scripts/**/*.md" "tinybird/**/*.md"
+ "*.md" ".claude/**/*.md" ".github/**/*.md" "crates/**/*.md" "scripts/**/*.md" "scripts/**/*.mjs" "tinybird/**/*.md"
- name: Build with VitePress (fails on dead links)
run: npm run build
@@ -180,7 +180,7 @@ jobs:
- name: Retrieve Node.js version
id: node-version
- run: echo "node-version=$(grep '^nodejs ' .tool-versions | awk '{print $2}')" >> $GITHUB_OUTPUT
+ run: echo "node-version=$(grep '^nodejs ' .tool-versions | awk '{print $2}')" >> "$GITHUB_OUTPUT"
shell: bash
- name: Use Node.js
diff --git a/.github/workflows/release-validation.mjs b/.github/workflows/release-validation.mjs
new file mode 100644
index 0000000000..ffb740a0dd
--- /dev/null
+++ b/.github/workflows/release-validation.mjs
@@ -0,0 +1,339 @@
+import { readFile } from 'node:fs/promises'
+import { pathToFileURL } from 'node:url'
+
+const shaPattern = /^[0-9a-f]{40}$/
+const numericIdentifier = '(?:0|[1-9][0-9]*)'
+const prereleaseIdentifier = `(?:${numericIdentifier}|[0-9]*[A-Za-z-][0-9A-Za-z-]*)`
+const tagPattern = new RegExp(
+ `^v${numericIdentifier}\\.${numericIdentifier}\\.${numericIdentifier}(?:-${prereleaseIdentifier}(?:\\.${prereleaseIdentifier})*)?(?:\\+[0-9A-Za-z-]+(?:\\.[0-9A-Za-z-]+)*)?$`
+)
+const requireValue = (condition, message) => {
+ if (!condition) throw new Error(message)
+}
+
+export function validateContext(env, policy) {
+ const source = env.SOURCE_SHA
+ requireValue(
+ shaPattern.test(source ?? '') &&
+ source === env.GITHUB_SHA &&
+ source === env.WORKFLOW_SHA,
+ 'Source and workflow must be the same full commit'
+ )
+ requireValue(
+ policy.cohort === 'phase1-fastly-cli' &&
+ env.GITHUB_REPOSITORY === policy.repository &&
+ env.GITHUB_REPOSITORY_ID === policy.repository_id &&
+ env.GITHUB_REPOSITORY_OWNER_ID === policy.repository_owner_id,
+ 'Official repository identity required'
+ )
+ requireValue(
+ env.GITHUB_EVENT_NAME === 'push' && policy.identity.event === 'push',
+ 'Tag push event required'
+ )
+ const ref = env.GITHUB_REF
+ requireValue(
+ typeof ref === 'string' &&
+ ref.startsWith('refs/tags/') &&
+ tagPattern.test(ref.slice(10)),
+ 'Strict SemVer full tag ref required'
+ )
+ requireValue(
+ Buffer.byteLength(ref.slice(10), 'utf8') <= 128,
+ 'Tag exceeds 128 byte maximum'
+ )
+ requireValue(
+ env.WORKFLOW_REF ===
+ `${policy.repository}/${policy.identity.caller_path}@${ref}` &&
+ policy.identity.caller_path === '.github/workflows/release.yml',
+ 'Exact same-tag caller workflow required'
+ )
+ requireValue(
+ /^[1-9][0-9]*$/.test(env.GITHUB_RUN_ID ?? '') &&
+ /^[1-9][0-9]*$/.test(env.GITHUB_RUN_ATTEMPT ?? ''),
+ 'Current run and attempt required'
+ )
+ return {
+ source,
+ ref,
+ tag: ref.slice(10),
+ repository: policy.repository,
+ repositoryId: policy.repository_id,
+ ownerId: policy.repository_owner_id,
+ runId: env.GITHUB_RUN_ID,
+ attempt: env.GITHUB_RUN_ATTEMPT,
+ }
+}
+
+export const governanceGap =
+ 'Production source eligibility unavailable: missing independently approved governance, ruleset, and bypass approval profile'
+
+export async function verifySource(context, api) {
+ const base = `/repos/${context.repository}`
+ const tag = await api(
+ `${base}/git/ref/tags/${encodeURIComponent(context.tag)}`
+ )
+ requireValue(
+ tag.ref === context.ref &&
+ tag.object?.type === 'commit' &&
+ tag.object.sha === context.source,
+ 'Remote lightweight tag must target exact source commit'
+ )
+ const main = await api(`${base}/git/ref/heads/main`)
+ requireValue(
+ main.ref === 'refs/heads/main' &&
+ main.object?.type === 'commit' &&
+ shaPattern.test(main.object.sha ?? ''),
+ 'Remote main commit required'
+ )
+ const comparison = await api(
+ `${base}/compare/${context.source}...${main.object.sha}`
+ )
+ requireValue(
+ ['ahead', 'identical'].includes(comparison.status) &&
+ comparison.merge_base_commit?.sha === context.source,
+ 'Source must be an ancestor of remote main'
+ )
+}
+
+export async function admit(context, api) {
+ await verifySource(context, api)
+ // Ancestry observations and validation gates do not establish review authority.
+ return { source_eligibility: false, diagnostic: governanceGap }
+}
+
+export const requiredJobNames = Object.freeze([
+ 'Release validation admission',
+ 'Release validation Fastly',
+ 'Release validation native and parity',
+ 'Release validation Cloudflare and Spin',
+ 'Release validation release tool Linux',
+ 'Release validation release tool macOS',
+ 'Release validation JavaScript',
+ 'Release validation docs',
+])
+
+export function inspectJobs(context, body, requiredNames = requiredJobNames) {
+ requireValue(
+ Number.isInteger(body.total_count) &&
+ body.total_count <= 100 &&
+ Array.isArray(body.jobs) &&
+ body.jobs.length === body.total_count,
+ 'Jobs response is truncated or invalid'
+ )
+ const observed = new Map()
+ for (const job of body.jobs) {
+ const name = job.name?.split(' / ').at(-1)
+ if (!requiredNames.includes(name)) continue
+ requireValue(!observed.has(name), 'Duplicate required job')
+ requireValue(
+ String(job.run_id) === context.runId &&
+ (job.run_attempt === undefined ||
+ String(job.run_attempt) === context.attempt) &&
+ job.head_sha === context.source,
+ 'Job must belong to current source, run, and attempt'
+ )
+ requireValue(
+ [
+ 'queued',
+ 'in_progress',
+ 'waiting',
+ 'pending',
+ 'requested',
+ 'completed',
+ ].includes(job.status),
+ 'Unknown required job status'
+ )
+ if (job.status === 'completed')
+ requireValue(
+ job.conclusion === 'success',
+ 'Required job does not succeed. Rerun all jobs within the current validation window; no previous-attempt results count'
+ )
+ else
+ requireValue(
+ job.conclusion === null,
+ 'Pending required job has a conclusion'
+ )
+ observed.set(name, job.status === 'completed')
+ }
+ return requiredNames.every((name) => observed.get(name) === true)
+}
+
+function validateRun(context, run) {
+ requireValue(
+ String(run.id) === context.runId &&
+ String(run.run_attempt) === context.attempt &&
+ run.head_sha === context.source &&
+ run.head_branch === context.tag &&
+ run.event === 'push' &&
+ [
+ '.github/workflows/release.yml',
+ `.github/workflows/release.yml@${context.ref}`,
+ ].includes(run.path),
+ 'Run must match current tag, source, caller, event, and attempt'
+ )
+ requireValue(
+ run.repository?.full_name === context.repository &&
+ String(run.repository.id) === context.repositoryId &&
+ String(run.repository.owner?.id) === context.ownerId,
+ 'Run repository identity mismatch'
+ )
+ requireValue(
+ Number.isFinite(Date.parse(run.created_at)),
+ 'Run creation timestamp required'
+ )
+}
+
+const deadlineMessage =
+ '90 minute validation deadline exceeded (includes queue time). Reruns cannot reset this deadline; a fresh trusted invocation is required'
+
+export async function wait(
+ context,
+ {
+ api,
+ requiredNames = requiredJobNames,
+ now = Date.now,
+ monotonic = () => performance.now(),
+ sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
+ }
+) {
+ const start = monotonic()
+ const wallStart = now()
+ const base = `/repos/${context.repository}/actions/runs/${context.runId}/attempts/${context.attempt}`
+ const run = await api(base, 90 * 60 * 1000)
+ validateRun(context, run)
+ const budget = Date.parse(run.created_at) + 90 * 60 * 1000 - wallStart
+ const remaining = () => budget - (monotonic() - start)
+ const boundedApi = (path) => {
+ requireValue(remaining() > 0, deadlineMessage)
+ return api(path, remaining())
+ }
+ requireValue(
+ budget <= 90 * 60 * 1000,
+ 'Run creation timestamp is in the future'
+ )
+ while (true) {
+ requireValue(remaining() > 0, deadlineMessage)
+ const current = await boundedApi(base)
+ validateRun(context, current)
+ requireValue(
+ current.created_at === run.created_at,
+ 'Run creation timestamp changed'
+ )
+ const body = await boundedApi(`${base}/jobs?per_page=100`)
+ requireValue(remaining() > 0, deadlineMessage)
+ if (inspectJobs(context, body, requiredNames)) {
+ await verifySource(context, boundedApi)
+ requireValue(remaining() > 0, deadlineMessage)
+ throw new Error(governanceGap)
+ }
+ await sleep(Math.min(15000, remaining()))
+ }
+}
+
+// The fixed official REST origin receives the explicit read-only token.
+export const apiOrigin = 'https://api.github.com' // allow-domain: api.github.com
+
+export function createApi(token, fetchImpl = fetch) {
+ requireValue(
+ typeof token === 'string' && token.length > 0 && !/[\r\n]/.test(token),
+ 'Explicit read token required'
+ )
+ return async (path, remainingBudget = 15000) => {
+ requireValue(
+ Number.isFinite(remainingBudget) && remainingBudget > 0,
+ deadlineMessage
+ )
+ requireValue(
+ typeof path === 'string' &&
+ /^\/repos\/[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+\/(?:git\/ref\/heads\/main|git\/ref\/tags\/v[A-Za-z0-9.%+-]+|compare\/[a-f0-9]{40}\.\.\.[a-f0-9]{40}|actions\/runs\/[1-9][0-9]*\/attempts\/[1-9][0-9]*(?:\/jobs\?per_page=100)?)$/.test(
+ path
+ ),
+ 'Fixed repository API path required'
+ )
+ const controller = new AbortController()
+ const timer = setTimeout(
+ () => controller.abort(),
+ Math.min(15000, remainingBudget)
+ )
+ let reader
+ try {
+ const response = await fetchImpl(`${apiOrigin}${path}`, {
+ method: 'GET',
+ redirect: 'error',
+ signal: controller.signal,
+ headers: {
+ Authorization: `Bearer ${token}`,
+ Accept: 'application/vnd.github+json',
+ 'X-GitHub-Api-Version': '2022-11-28',
+ },
+ })
+ requireValue(
+ response.ok && !response.redirected,
+ 'GitHub API request unsuccessful'
+ )
+ requireValue(
+ !response.headers.get('link'),
+ 'Paginated GitHub API responses are unavailable'
+ )
+ const limit = 2 * 1024 * 1024
+ const length = response.headers.get('content-length')
+ requireValue(
+ length === null || (/^[0-9]+$/.test(length) && Number(length) <= limit),
+ 'GitHub API body exceeds bound'
+ )
+ requireValue(response.body, 'GitHub API body required')
+ reader = response.body.getReader()
+ const chunks = []
+ let size = 0
+ while (true) {
+ const { done, value } = await reader.read()
+ if (done) break
+ size += value.byteLength
+ requireValue(size <= limit, 'GitHub API body exceeds bound')
+ chunks.push(value)
+ }
+ return JSON.parse(Buffer.concat(chunks, size).toString('utf8'))
+ } catch {
+ // Network and response errors may contain credentials or untrusted content.
+ throw new Error(
+ 'Bounded GitHub API read failed. Rerun all jobs within the current validation window; an expired window requires a fresh trusted invocation'
+ )
+ } finally {
+ clearTimeout(timer)
+ if (reader) await reader.cancel().catch(() => {})
+ }
+ }
+}
+
+// Importing this module for pure tests never invokes the CI entry point.
+if (
+ process.argv[1] &&
+ import.meta.url === pathToFileURL(process.argv[1]).href
+) {
+ try {
+ requireValue(
+ process.argv.length === 3 && ['admit', 'wait'].includes(process.argv[2]),
+ 'Expected admit or wait command'
+ )
+ const policy = JSON.parse(
+ await readFile(
+ new URL(
+ '../../crates/trusted-server-cli/release-policy.json',
+ import.meta.url
+ ),
+ 'utf8'
+ )
+ )
+ const context = validateContext(process.env, policy)
+ const api = createApi(process.env.GH_TOKEN)
+ if (process.argv[2] === 'admit') {
+ const result = await admit(context, api)
+ process.stdout.write(`${JSON.stringify(result)}\n`)
+ } else {
+ await wait(context, { api })
+ }
+ } catch (error) {
+ process.stderr.write(`${error.message}\n`)
+ process.exitCode = 1
+ }
+}
diff --git a/.github/workflows/release-validation.test.mjs b/.github/workflows/release-validation.test.mjs
new file mode 100644
index 0000000000..f961df2d61
--- /dev/null
+++ b/.github/workflows/release-validation.test.mjs
@@ -0,0 +1,462 @@
+import assert from 'node:assert/strict'
+import test from 'node:test'
+import * as validation from './release-validation.mjs'
+
+const sha = 'a'.repeat(40)
+const policy = {
+ repository: 'example-org/example-repo',
+ repository_id: '99999',
+ repository_owner_id: '99998',
+ cohort: 'phase1-fastly-cli',
+ identity: { event: 'push', caller_path: '.github/workflows/release.yml' },
+}
+const env = {
+ SOURCE_SHA: sha,
+ GITHUB_SHA: sha,
+ GITHUB_REF: 'refs/tags/v1.2.3',
+ GITHUB_EVENT_NAME: 'push',
+ GITHUB_REPOSITORY: policy.repository,
+ GITHUB_REPOSITORY_ID: '99999',
+ GITHUB_REPOSITORY_OWNER_ID: '99998',
+ GITHUB_RUN_ID: '123',
+ GITHUB_RUN_ATTEMPT: '2',
+ WORKFLOW_SHA: sha,
+ WORKFLOW_REF:
+ 'example-org/example-repo/.github/workflows/release.yml@refs/tags/v1.2.3',
+}
+
+test('context requires same source, official identity, event, and strict full SemVer tag', async (t) => {
+ assert.equal(typeof validation.validateContext, 'function')
+ assert.equal(validation.validateContext(env, policy).source, sha)
+ for (const change of [
+ { SOURCE_SHA: 'b'.repeat(40) },
+ { WORKFLOW_SHA: 'b'.repeat(40) },
+ { GITHUB_EVENT_NAME: 'workflow_dispatch' },
+ { GITHUB_REPOSITORY_ID: '99997' },
+ { GITHUB_REF: 'refs/heads/main' },
+ { GITHUB_REF: 'refs/tags/v01.2.3' },
+ { GITHUB_REF: 'refs/tags/v1.2.3-01' },
+ { GITHUB_REF: 'refs/tags/v1.2.3/' },
+ { GITHUB_RUN_ATTEMPT: '0' },
+ ]) {
+ await t.test(`rejects ${JSON.stringify(change)}`, () =>
+ assert.throws(() =>
+ validation.validateContext(
+ {
+ ...env,
+ ...change,
+ ...(change.GITHUB_REF
+ ? {
+ WORKFLOW_REF: `${policy.repository}/.github/workflows/release.yml@${change.GITHUB_REF}`,
+ }
+ : {}),
+ },
+ policy
+ )
+ )
+ )
+ }
+ for (const tag of ['v1.2.3+example.01', 'v1.2.3-rc.1']) {
+ await t.test(`parses ${tag} without eligibility authority`, () =>
+ assert.equal(
+ validation.validateContext(
+ {
+ ...env,
+ GITHUB_REF: `refs/tags/${tag}`,
+ WORKFLOW_REF: `example-org/example-repo/.github/workflows/release.yml@refs/tags/${tag}`,
+ },
+ policy
+ ).tag,
+ tag
+ )
+ )
+ }
+})
+
+const context = () => validation.validateContext(env, policy)
+const sourceApi =
+ (change = {}) =>
+ async (path) => {
+ if (path.includes('/git/ref/tags/'))
+ return {
+ ref: env.GITHUB_REF,
+ object: { type: 'commit', sha },
+ ...change.tag,
+ }
+ if (path.endsWith('/git/ref/heads/main'))
+ return {
+ ref: 'refs/heads/main',
+ object: { type: 'commit', sha: 'b'.repeat(40) },
+ ...change.main,
+ }
+ if (path.includes('/compare/'))
+ return { status: 'ahead', merge_base_commit: { sha }, ...change.compare }
+ throw new Error('Unexpected fixture endpoint')
+ }
+test('remote lightweight tag and main ancestry provide observations without eligibility authority', async (t) => {
+ assert.equal(typeof validation.admit, 'function')
+ assert.deepEqual(await validation.admit(context(), sourceApi()), {
+ source_eligibility: false,
+ diagnostic: validation.governanceGap,
+ })
+ for (const change of [
+ { tag: { object: { type: 'tag', sha } } },
+ { tag: { object: { type: 'commit', sha: 'b'.repeat(40) } } },
+ { compare: { status: 'behind' } },
+ { compare: { merge_base_commit: { sha: 'b'.repeat(40) } } },
+ ]) {
+ await t.test(`rejects remote ${JSON.stringify(change)}`, async () =>
+ assert.rejects(validation.admit(context(), sourceApi(change)))
+ )
+ }
+ const rc = validation.validateContext(
+ {
+ ...env,
+ GITHUB_REF: 'refs/tags/v1.2.3-rc.1',
+ WORKFLOW_REF:
+ 'example-org/example-repo/.github/workflows/release.yml@refs/tags/v1.2.3-rc.1',
+ CANDIDATE_ALLOWLIST: sha,
+ LOCAL_REF: 'refs/heads/main',
+ },
+ policy
+ )
+ const rcApi = async (path) =>
+ path.includes('/git/ref/tags/')
+ ? { ref: rc.ref, object: { type: 'commit', sha } }
+ : sourceApi()(path)
+ assert.equal((await validation.admit(rc, rcApi)).source_eligibility, false)
+})
+
+const requiredNames = [
+ 'Release validation admission',
+ 'Release validation Fastly',
+]
+const run = () => ({
+ id: 123,
+ run_attempt: 2,
+ head_sha: sha,
+ head_branch: 'v1.2.3',
+ event: 'push',
+ path: '.github/workflows/release.yml',
+ created_at: '2026-01-01T00:00:00Z',
+ repository: { full_name: policy.repository, id: 99999, owner: { id: 99998 } },
+})
+const jobs = () => ({
+ total_count: 2,
+ jobs: requiredNames.map((name, index) => ({
+ id: index + 1,
+ name: `caller / ${name}`,
+ run_id: 123,
+ run_attempt: 2,
+ head_sha: sha,
+ status: 'completed',
+ conclusion: 'success',
+ })),
+})
+test('waiter binds exact gates to own run attempt and fails closed on every terminal outcome', async (t) => {
+ assert.equal(typeof validation.inspectJobs, 'function')
+ assert.equal(validation.inspectJobs(context(), jobs(), requiredNames), true)
+ for (const change of [
+ { run_id: 124 },
+ { run_attempt: 1 },
+ { head_sha: 'b'.repeat(40) },
+ { conclusion: 'skipped' },
+ { conclusion: 'cancelled' },
+ { conclusion: 'timed_out' },
+ { conclusion: 'failure' },
+ { name: 'unrelated success' },
+ ]) {
+ await t.test(`gate rejects or waits for ${JSON.stringify(change)}`, () => {
+ const body = jobs()
+ Object.assign(body.jobs[0], change)
+ if (change.name)
+ assert.equal(
+ validation.inspectJobs(context(), body, requiredNames),
+ false
+ )
+ else
+ assert.throws(() =>
+ validation.inspectJobs(context(), body, requiredNames)
+ )
+ })
+ }
+ const duplicate = jobs()
+ duplicate.jobs[1].name = duplicate.jobs[0].name
+ assert.throws(
+ () => validation.inspectJobs(context(), duplicate, requiredNames),
+ /Duplicate/
+ )
+ assert.throws(
+ () =>
+ validation.inspectJobs(
+ context(),
+ { ...jobs(), total_count: 101 },
+ requiredNames
+ ),
+ /truncated/
+ )
+ const pending = jobs()
+ pending.jobs[0].status = 'queued'
+ pending.jobs[0].conclusion = null
+ assert.equal(validation.inspectJobs(context(), pending, requiredNames), false)
+})
+test('waiter deadline includes queue time and successful gates never approve governance', async (t) => {
+ assert.equal(typeof validation.wait, 'function')
+ const api = async (path) =>
+ path.endsWith('/jobs?per_page=100')
+ ? jobs()
+ : path.includes('/actions/runs/')
+ ? run()
+ : sourceApi()(path)
+ const options = {
+ api,
+ requiredNames,
+ now: () => Date.parse('2026-01-01T00:01:00Z'),
+ monotonic: () => 100,
+ sleep: async () => {},
+ }
+ await assert.rejects(
+ validation.wait(context(), options),
+ /missing independently approved governance/
+ )
+ await assert.rejects(
+ validation.wait(context(), {
+ ...options,
+ now: () => Date.parse('2026-01-01T01:30:00Z'),
+ }),
+ /90 minute/
+ )
+ for (const change of [
+ { run_attempt: 1 },
+ { head_sha: 'b'.repeat(40) },
+ { event: 'workflow_dispatch' },
+ { head_branch: 'main' },
+ {
+ repository: {
+ full_name: policy.repository,
+ id: 99997,
+ owner: { id: 99998 },
+ },
+ },
+ ]) {
+ await t.test(`rejects run ${JSON.stringify(change)}`, async () =>
+ assert.rejects(
+ validation.wait(context(), {
+ ...options,
+ api: async (path) =>
+ path.includes('/actions/runs/') && !path.includes('/jobs?')
+ ? { ...run(), ...change }
+ : api(path),
+ }),
+ change.repository
+ ? /Run repository identity mismatch/
+ : /Run must match/
+ )
+ )
+ }
+ let elapsed = 0
+ const queued = jobs()
+ queued.jobs[0].status = 'queued'
+ queued.jobs[0].conclusion = null
+ await assert.rejects(
+ validation.wait(context(), {
+ ...options,
+ api: async (path) =>
+ path.endsWith('/jobs?per_page=100') ? queued : api(path),
+ now: () => Date.parse('2026-01-01T01:29:59Z'),
+ monotonic: () => elapsed,
+ sleep: async (ms) => {
+ elapsed += ms
+ },
+ }),
+ /90 minute/
+ )
+})
+
+test('transport fixes origin, blocks redirects and pagination, bounds body, and keeps credentials out of errors', async () => {
+ assert.equal(typeof validation.createApi, 'function')
+ let seen
+ const api = validation.createApi('example-token', async (url, options) => {
+ seen = { url, options }
+ return new Response('{"value":1}')
+ })
+ assert.deepEqual(
+ await api('/repos/example-org/example-repo/git/ref/heads/main'),
+ { value: 1 }
+ )
+ assert.equal(
+ seen.url,
+ `${validation.apiOrigin}/repos/example-org/example-repo/git/ref/heads/main`
+ )
+ assert.equal(seen.options.redirect, 'error')
+ assert.equal(seen.options.headers.Authorization, 'Bearer example-token')
+ assert.ok(seen.options.signal instanceof AbortSignal)
+ assert.throws(() => validation.createApi(''))
+ await assert.rejects(api('https://example.com/'), /path/)
+ for (const response of [
+ new Response('{}', { status: 302 }),
+ new Response('{}', {
+ headers: { link: '; rel="next"' },
+ }),
+ new Response('x'.repeat(2 * 1024 * 1024 + 1)),
+ new Response('{}', { headers: { 'content-length': '9999999' } }),
+ ]) {
+ await assert.rejects(
+ validation.createApi(
+ 'example-token',
+ async () => response
+ )('/repos/example-org/example-repo/git/ref/heads/main')
+ )
+ }
+ await assert.rejects(
+ validation.createApi('example-token', async () => {
+ throw new Error('example-token')
+ })('/repos/example-org/example-repo/git/ref/heads/main'),
+ (error) => !error.message.includes('example-token')
+ )
+})
+
+test('tag byte length matches the consumer maximum', () => {
+ const tag = `v1.2.3+${'a'.repeat(122)}`
+ assert.throws(
+ () =>
+ validation.validateContext(
+ {
+ ...env,
+ GITHUB_REF: `refs/tags/${tag}`,
+ WORKFLOW_REF: `${policy.repository}/.github/workflows/release.yml@refs/tags/${tag}`,
+ },
+ policy
+ ),
+ /128/
+ )
+})
+
+test('attempt endpoint supports documented jobs without a job attempt field', async () => {
+ const body = jobs()
+ for (const job of body.jobs) delete job.run_attempt
+ assert.equal(validation.inspectJobs(context(), body, requiredNames), true)
+})
+
+for (const conclusion of ['skipped', 'cancelled', 'timed_out']) {
+ test(`${conclusion} gate tells operators to rerun all jobs`, () => {
+ const body = jobs()
+ body.jobs[0].conclusion = conclusion
+ assert.throws(
+ () => validation.inspectJobs(context(), body, requiredNames),
+ /Rerun all jobs/
+ )
+ })
+}
+
+test('all waiter reads receive the remaining global deadline including source reads', async () => {
+ let elapsed = 0
+ const calls = []
+ const api = async (path, remaining) => {
+ calls.push({ path, remaining })
+ assert.ok(remaining > 0 && remaining <= 5400000)
+ elapsed += 1
+ return path.endsWith('/jobs?per_page=100')
+ ? jobs()
+ : path.includes('/actions/runs/')
+ ? run()
+ : sourceApi()(path)
+ }
+ await assert.rejects(
+ validation.wait(context(), {
+ api,
+ requiredNames,
+ now: () => Date.parse('2026-01-01T01:29:59Z'),
+ monotonic: () => elapsed,
+ }),
+ /missing independently approved governance/
+ )
+ assert.equal(calls.length, 6)
+ assert.ok(calls.slice(1).every(({ remaining }) => remaining <= 1000))
+})
+
+test('API request abort is clipped to the remaining global budget', async (t) => {
+ t.mock.timers.enable({ apis: ['setTimeout'] })
+ let signal
+ const api = validation.createApi('example-token', async (_url, options) => {
+ signal = options.signal
+ return new Promise((_resolve, reject) =>
+ signal.addEventListener('abort', () => reject(new Error('aborted')), {
+ once: true,
+ })
+ )
+ })
+ const pending = api('/repos/example-org/example-repo/git/ref/heads/main', 10)
+ t.mock.timers.tick(10)
+ assert.equal(signal.aborted, true)
+ await assert.rejects(pending, /rerun|Rerun/)
+})
+
+test('run path accepts only exact bare caller or caller at the current tag', async () => {
+ const api = async (path) =>
+ path.endsWith('/jobs?per_page=100')
+ ? jobs()
+ : path.includes('/actions/runs/')
+ ? { ...run(), path: `.github/workflows/release.yml@${env.GITHUB_REF}` }
+ : sourceApi()(path)
+ await assert.rejects(
+ validation.wait(context(), {
+ api,
+ requiredNames,
+ now: () => Date.parse('2026-01-01T00:01:00Z'),
+ }),
+ /missing independently approved governance/
+ )
+})
+
+test('retry does not reset the queue inclusive deadline and explains fresh invocation requirement', async () => {
+ const api = async () => ({ ...run(), run_started_at: '2026-01-01T02:00:00Z' })
+ await assert.rejects(
+ validation.wait(context(), {
+ api,
+ now: () => Date.parse('2026-01-01T02:00:00Z'),
+ }),
+ /fresh trusted invocation/
+ )
+})
+
+for (const extra of [
+ { CANDIDATE_ALLOWLIST: sha },
+ { LOCAL_REF: 'refs/heads/main' },
+]) {
+ test(`unlisted RC remains ineligible despite ${Object.keys(extra)[0]}`, async () => {
+ const tag = 'v1.2.3-rc.1'
+ const current = validation.validateContext(
+ {
+ ...env,
+ ...extra,
+ GITHUB_REF: `refs/tags/${tag}`,
+ WORKFLOW_REF: `${policy.repository}/.github/workflows/release.yml@refs/tags/${tag}`,
+ },
+ policy
+ )
+ const api = async (path) =>
+ path.includes('/git/ref/tags/')
+ ? { ref: current.ref, object: { type: 'commit', sha } }
+ : sourceApi()(path)
+ assert.equal(
+ (await validation.admit(current, api)).source_eligibility,
+ false
+ )
+ })
+}
+
+test('admission composes with bounded transport through the remote compare endpoint', async () => {
+ const paths = []
+ const api = validation.createApi('example-token', async (url) => {
+ const path = new URL(url).pathname
+ paths.push(path)
+ return Response.json(await sourceApi()(path))
+ })
+ assert.deepEqual(await validation.admit(context(), api), {
+ source_eligibility: false,
+ diagnostic: validation.governanceGap,
+ })
+ assert.equal(paths.length, 3)
+ assert.ok(paths[2].endsWith(`/compare/${sha}...${'b'.repeat(40)}`))
+})
diff --git a/.github/workflows/release-validation.yml b/.github/workflows/release-validation.yml
new file mode 100644
index 0000000000..350d093c2c
--- /dev/null
+++ b/.github/workflows/release-validation.yml
@@ -0,0 +1,428 @@
+# Callable quality validation only. No release trigger or publication authority.
+# Governance approval is not configured: the terminal result remains closed.
+name: Release validation
+
+on:
+ workflow_call:
+ inputs:
+ source_sha:
+ description: Exact lowercase source commit; must equal the caller revision.
+ required: true
+ type: string
+
+permissions:
+ contents: read
+
+defaults:
+ run:
+ shell: bash
+
+jobs:
+ admission:
+ name: Release validation admission
+ runs-on: ubuntu-24.04
+ timeout-minutes: 10
+ steps:
+ - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 (Node 24)
+ with:
+ ref: ${{ inputs.source_sha }}
+ persist-credentials: false
+ - name: Read pinned Node version
+ id: node
+ shell: bash
+ run: echo "version=$(awk '$1 == "nodejs" { print $2 }' .tool-versions)" >> "$GITHUB_OUTPUT"
+ - uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0 (Node 24)
+ with:
+ node-version: ${{ steps.node.outputs.version }}
+ - name: Check same-commit invocation and remote lightweight tag
+ env:
+ SOURCE_SHA: ${{ inputs.source_sha }}
+ WORKFLOW_SHA: ${{ github.workflow_sha }}
+ WORKFLOW_REF: ${{ github.workflow_ref }}
+ GH_TOKEN: ${{ github.token }}
+ shell: bash
+ run: |
+ node .github/workflows/release-validation.mjs admit
+
+ fastly:
+ name: Release validation Fastly
+ needs: admission
+ runs-on: ubuntu-24.04
+ timeout-minutes: 75
+ steps:
+ - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 (Node 24)
+ with:
+ ref: ${{ inputs.source_sha }}
+ persist-credentials: false
+ - name: Read pinned Node version
+ id: node
+ shell: bash
+ run: echo "version=$(awk '$1 == "nodejs" { print $2 }' .tool-versions)" >> "$GITHUB_OUTPUT"
+ - uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0 (Node 24)
+ with:
+ node-version: ${{ steps.node.outputs.version }}
+ # Shell installation follows the existing native release-tool gate and
+ # avoids inheriting floating composite-action dependencies or shared caches.
+ - name: Install pinned Rust toolchain
+ shell: bash
+ run: |
+ set -euo pipefail
+ RUST_VERSION="$(awk '$1 == "rust" { print $2 }' .tool-versions)"
+ rustup toolchain install "$RUST_VERSION" --profile minimal --component clippy,rustfmt --target wasm32-wasip1
+ rustup override set "$RUST_VERSION"
+ HOST_TARGET="$(rustc -vV | sed -n 's/^host: //p')"
+ test -n "$HOST_TARGET"
+ echo "HOST_TARGET=$HOST_TARGET" >> "$GITHUB_ENV"
+ - name: Install JavaScript build dependencies
+ shell: bash
+ run: |
+ npm ci --prefix crates/trusted-server-js/lib
+ - name: Install pinned Viceroy
+ shell: bash
+ run: |
+ VICEROY_VERSION="$(awk '$1 == "viceroy" { print $2 }' .tool-versions)"
+ cargo install viceroy --version "$VICEROY_VERSION" --locked --force
+ - name: Run Fastly gates
+ shell: bash
+ run: |
+ set -euo pipefail
+ cargo test-fastly
+ cargo test-fastly-reuse
+ cargo clippy-fastly
+ BID_DELAY=3 ./scripts/template-cache-local-test.sh esi
+ BID_DELAY=3 ./scripts/template-cache-local-test.sh inline
+ BID_DELAY=3 ./scripts/template-cache-local-test.sh purge
+
+ native:
+ name: Release validation native and parity
+ needs: admission
+ runs-on: ubuntu-24.04
+ timeout-minutes: 75
+ steps:
+ - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 (Node 24)
+ with:
+ ref: ${{ inputs.source_sha }}
+ persist-credentials: false
+ - name: Read pinned Node version
+ id: node
+ shell: bash
+ run: echo "version=$(awk '$1 == "nodejs" { print $2 }' .tool-versions)" >> "$GITHUB_OUTPUT"
+ - uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0 (Node 24)
+ with:
+ node-version: ${{ steps.node.outputs.version }}
+ # Shell installation follows the existing native release-tool gate and
+ # avoids inheriting floating composite-action dependencies or shared caches.
+ - name: Install pinned Rust toolchain
+ shell: bash
+ run: |
+ set -euo pipefail
+ RUST_VERSION="$(awk '$1 == "rust" { print $2 }' .tool-versions)"
+ rustup toolchain install "$RUST_VERSION" --profile minimal --component clippy,rustfmt --target wasm32-wasip1
+ rustup override set "$RUST_VERSION"
+ HOST_TARGET="$(rustc -vV | sed -n 's/^host: //p')"
+ test -n "$HOST_TARGET"
+ echo "HOST_TARGET=$HOST_TARGET" >> "$GITHUB_ENV"
+ - name: Install JavaScript build dependencies
+ shell: bash
+ run: |
+ npm ci --prefix crates/trusted-server-js/lib
+ - name: Install native trust test tools
+ shell: bash
+ run: |
+ sudo apt-get update
+ sudo apt-get install --yes libnss3-tools openssl
+ - name: Run native and parity gates
+ shell: bash
+ run: |
+ set -euo pipefail
+ cargo fmt --all -- --check
+ cargo build -p trusted-server-adapter-axum
+ cargo test-axum
+ cargo clippy-axum
+ cargo test -p trusted-server-core --test template_build_digest
+ cargo clippy -p trusted-server-core --test template_build_digest -- -D warnings
+ cargo bench -p trusted-server-core --bench html_processor_bench -- --test
+ ./scripts/test-cli.sh
+ cargo clippy-cli
+ cargo test -p trusted-server-openrtb-codegen --target "$HOST_TARGET"
+ cargo clippy-codegen
+ cargo fmt --manifest-path crates/trusted-server-integration-tests/Cargo.toml -- --check
+ cargo test --manifest-path crates/trusted-server-integration-tests/Cargo.toml --test parity
+ cargo clippy --manifest-path crates/trusted-server-integration-tests/Cargo.toml --all-targets -- -D warnings
+ cargo build --package trusted-server-adapter-fastly --release --target wasm32-wasip1
+ - name: Guard and run real NSS trust fixtures
+ shell: bash
+ run: |
+ set -euo pipefail
+ TEST_LIST="$(mktemp)"
+ trap 'rm -f "$TEST_LIST"' EXIT
+ cargo test --package trusted-server-cli --target "$HOST_TARGET" --test proxy_trust_linux -- --ignored --list > "$TEST_LIST"
+ awk '/: test$/ { count++ } END { if (count == 0) exit 1 }' "$TEST_LIST"
+ cargo test --package trusted-server-cli --target "$HOST_TARGET" --test proxy_trust_linux -- --ignored
+
+ runtimes:
+ name: Release validation Cloudflare and Spin
+ needs: admission
+ runs-on: ubuntu-24.04
+ timeout-minutes: 75
+ steps:
+ - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 (Node 24)
+ with:
+ ref: ${{ inputs.source_sha }}
+ persist-credentials: false
+ - name: Read pinned Node version
+ id: node
+ shell: bash
+ run: echo "version=$(awk '$1 == "nodejs" { print $2 }' .tool-versions)" >> "$GITHUB_OUTPUT"
+ - uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0 (Node 24)
+ with:
+ node-version: ${{ steps.node.outputs.version }}
+ # Shell installation follows the existing native release-tool gate and
+ # avoids inheriting floating composite-action dependencies or shared caches.
+ - name: Install pinned Rust toolchain
+ shell: bash
+ run: |
+ set -euo pipefail
+ RUST_VERSION="$(awk '$1 == "rust" { print $2 }' .tool-versions)"
+ rustup toolchain install "$RUST_VERSION" --profile minimal --component clippy,rustfmt --target wasm32-wasip1,wasm32-unknown-unknown
+ rustup override set "$RUST_VERSION"
+ HOST_TARGET="$(rustc -vV | sed -n 's/^host: //p')"
+ test -n "$HOST_TARGET"
+ echo "HOST_TARGET=$HOST_TARGET" >> "$GITHUB_ENV"
+ - name: Install JavaScript build dependencies
+ shell: bash
+ run: |
+ npm ci --prefix crates/trusted-server-js/lib
+ - name: Run Cloudflare and Spin gates
+ shell: bash
+ run: |
+ set -euo pipefail
+ cargo check -p trusted-server-adapter-cloudflare
+ cargo check-cloudflare
+ cargo test-cloudflare
+ cargo clippy-cloudflare
+ cargo clippy-cloudflare-wasm
+ scripts/lint-wasm-clock.sh
+ cargo check -p trusted-server-adapter-spin
+ cargo check-spin
+ cargo test-spin
+ cargo clippy-spin-native
+ cargo clippy-spin-wasm
+ - name: Build Spin release artifact
+ env:
+ TRUSTED_SERVER__PUBLISHER__ORIGIN_URL: http://127.0.0.1:8080
+ TRUSTED_SERVER__PUBLISHER__PROXY_SECRET: example-integration-secret
+ TRUSTED_SERVER__EC__PASSPHRASE: example-ec-passphrase-padded-to-32
+ TRUSTED_SERVER__PROXY__CERTIFICATE_CHECK: 'false'
+ shell: bash
+ run: |
+ cargo build --package trusted-server-adapter-spin --target wasm32-wasip1 --features spin --release
+
+ release-tool-linux:
+ name: Release validation release tool Linux
+ needs: admission
+ runs-on: ubuntu-24.04
+ timeout-minutes: 75
+ steps:
+ - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 (Node 24)
+ with:
+ ref: ${{ inputs.source_sha }}
+ persist-credentials: false
+ - name: Read pinned Node version
+ id: node
+ shell: bash
+ run: echo "version=$(awk '$1 == "nodejs" { print $2 }' .tool-versions)" >> "$GITHUB_OUTPUT"
+ - uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0 (Node 24)
+ with:
+ node-version: ${{ steps.node.outputs.version }}
+ # Shell installation follows the existing native release-tool gate and
+ # avoids inheriting floating composite-action dependencies or shared caches.
+ - name: Install pinned Rust toolchain
+ shell: bash
+ run: |
+ set -euo pipefail
+ RUST_VERSION="$(awk '$1 == "rust" { print $2 }' .tool-versions)"
+ rustup toolchain install "$RUST_VERSION" --profile minimal --component clippy,rustfmt
+ rustup override set "$RUST_VERSION"
+ HOST_TARGET="$(rustc -vV | sed -n 's/^host: //p')"
+ test -n "$HOST_TARGET"
+ echo "HOST_TARGET=$HOST_TARGET" >> "$GITHUB_ENV"
+ - name: Install JavaScript build dependencies
+ shell: bash
+ run: |
+ npm ci --prefix crates/trusted-server-js/lib
+ - name: Check the reviewed workflow contract
+ shell: bash
+ run: |
+ set -euo pipefail
+ cargo run --package trusted-server-release --target "$HOST_TARGET" --locked -- check-workflow-contract --workflow .github/workflows/release-validation.yml
+ - name: Guard and run host release-tool and prebuilt gates
+ shell: bash
+ run: |
+ set -euo pipefail
+ TEST_LIST="$(mktemp)"
+ trap 'rm -f "$TEST_LIST"' EXIT
+ cargo test -p trusted-server-release --target "$HOST_TARGET" --all-features --locked -- --list > "$TEST_LIST"
+ awk '/: test$/ { count++ } END { if (count == 0) exit 1 }' "$TEST_LIST"
+ cargo test -p trusted-server-release --target "$HOST_TARGET" --all-features --locked
+ cargo clippy -p trusted-server-release --target "$HOST_TARGET" --all-targets --all-features --locked -- -D warnings
+ cargo doc -p trusted-server-release --target "$HOST_TARGET" --no-deps --all-features --locked
+ cargo test -p trusted-server-js --target "$HOST_TARGET" --test release_prebuilt --locked -- --list > "$TEST_LIST"
+ awk '/: test$/ { count++ } END { if (count == 0) exit 1 }' "$TEST_LIST"
+ cargo test -p trusted-server-js --target "$HOST_TARGET" --test release_prebuilt --locked
+ cargo clippy -p trusted-server-js --target "$HOST_TARGET" --all-targets --all-features --locked -- -D warnings
+
+ release-tool-macos:
+ name: Release validation release tool macOS
+ needs: admission
+ runs-on: macos-15
+ timeout-minutes: 75
+ steps:
+ - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 (Node 24)
+ with:
+ ref: ${{ inputs.source_sha }}
+ persist-credentials: false
+ - name: Read pinned Node version
+ id: node
+ shell: bash
+ run: echo "version=$(awk '$1 == "nodejs" { print $2 }' .tool-versions)" >> "$GITHUB_OUTPUT"
+ - uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0 (Node 24)
+ with:
+ node-version: ${{ steps.node.outputs.version }}
+ # Shell installation follows the existing native release-tool gate and
+ # avoids inheriting floating composite-action dependencies or shared caches.
+ - name: Install pinned Rust toolchain
+ shell: bash
+ run: |
+ set -euo pipefail
+ RUST_VERSION="$(awk '$1 == "rust" { print $2 }' .tool-versions)"
+ rustup toolchain install "$RUST_VERSION" --profile minimal --component clippy,rustfmt
+ rustup override set "$RUST_VERSION"
+ HOST_TARGET="$(rustc -vV | sed -n 's/^host: //p')"
+ test -n "$HOST_TARGET"
+ echo "HOST_TARGET=$HOST_TARGET" >> "$GITHUB_ENV"
+ - name: Install JavaScript build dependencies
+ shell: bash
+ run: |
+ npm ci --prefix crates/trusted-server-js/lib
+ - name: Check the reviewed workflow contract
+ shell: bash
+ run: |
+ set -euo pipefail
+ cargo run --package trusted-server-release --target "$HOST_TARGET" --locked -- check-workflow-contract --workflow .github/workflows/release-validation.yml
+ - name: Guard and run host release-tool and prebuilt gates
+ shell: bash
+ run: |
+ set -euo pipefail
+ TEST_LIST="$(mktemp)"
+ trap 'rm -f "$TEST_LIST"' EXIT
+ cargo test -p trusted-server-release --target "$HOST_TARGET" --all-features --locked -- --list > "$TEST_LIST"
+ awk '/: test$/ { count++ } END { if (count == 0) exit 1 }' "$TEST_LIST"
+ cargo test -p trusted-server-release --target "$HOST_TARGET" --all-features --locked
+ cargo clippy -p trusted-server-release --target "$HOST_TARGET" --all-targets --all-features --locked -- -D warnings
+ cargo doc -p trusted-server-release --target "$HOST_TARGET" --no-deps --all-features --locked
+ cargo test -p trusted-server-js --target "$HOST_TARGET" --test release_prebuilt --locked -- --list > "$TEST_LIST"
+ awk '/: test$/ { count++ } END { if (count == 0) exit 1 }' "$TEST_LIST"
+ cargo test -p trusted-server-js --target "$HOST_TARGET" --test release_prebuilt --locked
+ cargo clippy -p trusted-server-js --target "$HOST_TARGET" --all-targets --all-features --locked -- -D warnings
+
+ javascript:
+ name: Release validation JavaScript
+ needs: admission
+ runs-on: ubuntu-24.04
+ timeout-minutes: 30
+ steps:
+ - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 (Node 24)
+ with:
+ ref: ${{ inputs.source_sha }}
+ persist-credentials: false
+ - name: Read pinned Node version
+ id: node
+ shell: bash
+ run: echo "version=$(awk '$1 == "nodejs" { print $2 }' .tool-versions)" >> "$GITHUB_OUTPUT"
+ - uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0 (Node 24)
+ with:
+ node-version: ${{ steps.node.outputs.version }}
+ - name: Run JavaScript gates
+ env:
+ TRUSTED_SERVER__RELEASE_JS_TESTS: '1'
+ shell: bash
+ run: |
+ set -euo pipefail
+ npm ci --prefix crates/trusted-server-js/lib
+ npm run build --prefix crates/trusted-server-js/lib
+ npm test --prefix crates/trusted-server-js/lib -- --run
+ npm run lint --prefix crates/trusted-server-js/lib
+ npm run format --prefix crates/trusted-server-js/lib
+ node --test .github/workflows/release-validation.test.mjs
+
+ docs:
+ name: Release validation docs
+ needs: admission
+ runs-on: ubuntu-24.04
+ timeout-minutes: 30
+ steps:
+ - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 (Node 24)
+ with:
+ ref: ${{ inputs.source_sha }}
+ persist-credentials: false
+ - name: Read pinned Node version
+ id: node
+ shell: bash
+ run: echo "version=$(awk '$1 == "nodejs" { print $2 }' .tool-versions)" >> "$GITHUB_OUTPUT"
+ - uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0 (Node 24)
+ with:
+ node-version: ${{ steps.node.outputs.version }}
+ - name: Run documentation and proposal gates
+ shell: bash
+ run: |
+ set -euo pipefail
+ npm ci --prefix docs
+ npm run lint --prefix docs
+ npm run format --prefix docs
+ docs/node_modules/.bin/prettier --config docs/.prettierrc --check "*.md" ".claude/**/*.md" ".github/**/*.md" "crates/**/*.md" "scripts/**/*.md" "scripts/**/*.mjs" "tinybird/**/*.md"
+ npm run build --prefix docs
+ shellcheck scripts/docs-proposal/*.sh
+ node --test scripts/docs-proposal/hunks.test.mjs
+ scripts/docs-proposal/test.sh
+ - name: Verify instruction symlink and shared Rust setup policy
+ shell: bash
+ run: |
+ set -euo pipefail
+ test "$(git ls-tree HEAD CLAUDE.md | awk '{print $1}')" = 120000
+ test "$(git cat-file blob HEAD:CLAUDE.md)" = AGENTS.md
+ test -f AGENTS.md
+ if grep -rnE "uses:[[:space:]]*[\"']?(actions-rust-lang/setup-rust-toolchain|Swatinem/rust-cache)@" .github | grep -v '^\.github/actions/setup-rust/action\.yml:'; then
+ echo 'Use the shared Rust setup action rather than direct external Rust setup actions.' >&2
+ exit 1
+ fi
+
+ terminal-results:
+ name: Release validation terminal results
+ permissions:
+ contents: read
+ actions: read
+ needs: admission
+ runs-on: ubuntu-24.04
+ timeout-minutes: 90
+ steps:
+ - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 (Node 24)
+ with:
+ ref: ${{ inputs.source_sha }}
+ persist-credentials: false
+ - name: Read pinned Node version
+ id: node
+ shell: bash
+ run: echo "version=$(awk '$1 == "nodejs" { print $2 }' .tool-versions)" >> "$GITHUB_OUTPUT"
+ - uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0 (Node 24)
+ with:
+ node-version: ${{ steps.node.outputs.version }}
+ - name: Wait for this invocation and retain the governance block
+ env:
+ SOURCE_SHA: ${{ inputs.source_sha }}
+ WORKFLOW_SHA: ${{ github.workflow_sha }}
+ WORKFLOW_REF: ${{ github.workflow_ref }}
+ GH_TOKEN: ${{ github.token }}
+ shell: bash
+ run: |
+ node .github/workflows/release-validation.mjs wait
diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
index b27173dd66..e85f165ab4 100644
--- a/.github/workflows/test.yml
+++ b/.github/workflows/test.yml
@@ -1,4 +1,4 @@
-name: "Run Tests"
+name: 'Run Tests'
permissions:
contents: read
@@ -7,6 +7,11 @@ on:
push:
branches: [main]
pull_request:
+ # The scheduled and dispatched runs measure the native CLI reproduction
+ # builds that push and pull request runs defer (TS_RELEASE_NATIVE_REPRODUCTION).
+ schedule:
+ - cron: '23 4 * * *'
+ workflow_dispatch:
jobs:
test-rust:
@@ -20,12 +25,12 @@ jobs:
id: viceroy-version
# `.tool-versions` is the single source of truth so this workflow and
# `.github/actions/setup-integration-test-env/action.yml` can't drift.
- run: echo "viceroy-version=$(grep '^viceroy ' .tool-versions | awk '{print $2}')" >> $GITHUB_OUTPUT
+ run: echo "viceroy-version=$(grep '^viceroy ' .tool-versions | awk '{print $2}')" >> "$GITHUB_OUTPUT"
shell: bash
- name: Retrieve Node.js version
id: node-version
- run: echo "node-version=$(grep '^nodejs ' .tool-versions | awk '{print $2}')" >> $GITHUB_OUTPUT
+ run: echo "node-version=$(grep '^nodejs ' .tool-versions | awk '{print $2}')" >> "$GITHUB_OUTPUT"
shell: bash
- name: Set up Rust toolchain
@@ -153,15 +158,13 @@ jobs:
run: cargo check-spin
- name: Build Spin adapter release WASM
- # Mirror the Fastly release-build overrides so the artifact embeds usable
- # settings instead of the trusted-server.toml placeholders, which startup
- # rejects (falling back to the generic 503 router). This proves the
- # documented Spin artifact can boot, not just compile.
+ # Compile the documented Spin artifact. Application settings and secrets
+ # are supplied separately at runtime; compilation does not prove startup.
env:
TRUSTED_SERVER__PUBLISHER__ORIGIN_URL: http://127.0.0.1:8080
TRUSTED_SERVER__PUBLISHER__PROXY_SECRET: integration-test-proxy-secret
TRUSTED_SERVER__EC__PASSPHRASE: integration-test-ec-secret-padded-32
- TRUSTED_SERVER__PROXY__CERTIFICATE_CHECK: "false"
+ TRUSTED_SERVER__PROXY__CERTIFICATE_CHECK: 'false'
run: cargo build --package trusted-server-adapter-spin --target wasm32-wasip1 --features spin --release
- name: Run Spin adapter tests (native host)
@@ -201,7 +204,7 @@ jobs:
- name: Set up Rust toolchain
uses: ./.github/actions/setup-rust
with:
- components: "clippy, rustfmt"
+ components: 'clippy, rustfmt'
cache-key: cargo-cli
- name: Read pinned Node.js version for CLI fixtures
@@ -256,6 +259,1060 @@ jobs:
cargo test --manifest-path crates/trusted-server-cli/Cargo.toml --target "$TARGET" \
--test proxy_trust_linux -- --ignored
+ # Ordinary source-built behavior checks; this matrix is not release qualification.
+ test-release-native:
+ name: cargo test (release tooling and JS prebuilt, native)
+ strategy:
+ fail-fast: false
+ matrix:
+ os: [ubuntu-24.04, macos-15]
+ runs-on: ${{ matrix.os }}
+ timeout-minutes: 45
+ steps:
+ - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 (Node 24)
+ with:
+ persist-credentials: false
+ fetch-depth: 0
+
+ - name: Read pinned tool versions
+ id: tools
+ shell: bash
+ run: |
+ set -euo pipefail
+ echo "rust=$(awk '$1 == "rust" { print $2 }' .tool-versions)" >> "$GITHUB_OUTPUT"
+ echo "node=$(awk '$1 == "nodejs" { print $2 }' .tool-versions)" >> "$GITHUB_OUTPUT"
+
+ - name: Use pinned Node.js
+ uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0 (Node 24)
+ with:
+ node-version: ${{ steps.tools.outputs.node }}
+ cache: npm
+ cache-dependency-path: crates/trusted-server-js/lib/package-lock.json
+
+ - name: Install JS dependencies
+ working-directory: crates/trusted-server-js/lib
+ run: npm ci
+
+ # The runner's installed rustup bootstraps ordinary CI, not the release producer.
+ - name: Install pinned native Rust toolchain
+ env:
+ RUST_VERSION: ${{ steps.tools.outputs.rust }}
+ shell: bash
+ run: |
+ set -euo pipefail
+ rustup toolchain install "$RUST_VERSION" --profile minimal --component clippy
+ rustup override set "$RUST_VERSION"
+ HOST_TARGET="$(rustc -vV | sed -n 's/^host: //p')"
+ test -n "$HOST_TARGET"
+ echo "HOST_TARGET=$HOST_TARGET" >> "$GITHUB_ENV"
+
+ - name: Guard and run all native release-tool tests
+ shell: bash
+ run: |
+ set -euo pipefail
+ TEST_LIST="$(mktemp)"
+ trap 'rm -f "$TEST_LIST"' EXIT
+ cargo test -p trusted-server-release --target "$HOST_TARGET" --all-features -- --list > "$TEST_LIST"
+ awk '/: test$/ { count++ } END { if (count == 0) { print "No native release-tool tests selected"; exit 1 } print "Selected " count " native release-tool tests" }' "$TEST_LIST"
+ cargo test -p trusted-server-release --target "$HOST_TARGET" --all-features
+ cargo clippy -p trusted-server-release --target "$HOST_TARGET" --all-targets --all-features -- -D warnings
+ cargo doc -p trusted-server-release --target "$HOST_TARGET" --no-deps --all-features
+
+ - name: Prepare original-only full JS fixture (Linux)
+ if: runner.os == 'Linux'
+ shell: bash
+ run: |
+ set -euo pipefail
+ cargo build -p trusted-server-release --target "$HOST_TARGET" --locked
+ FIXTURE_ROOT="$RUNNER_TEMP/release-js-original-fixture"
+ node scripts/release/tests/prepare-js-fixture.mjs "$FIXTURE_ROOT" "target/$HOST_TARGET/debug/trusted-server-release"
+ echo "TS_RELEASE_JS_FIXTURE_ROOT=$FIXTURE_ROOT" >> "$GITHUB_ENV"
+
+ - name: Prove two fresh offline JS builds (Linux)
+ if: runner.os == 'Linux'
+ shell: bash
+ env:
+ # Optimize repeated whole-input hashes with test safety checks retained.
+ CARGO_PROFILE_TEST_OPT_LEVEL: '2'
+ CARGO_PROFILE_TEST_DEBUG_ASSERTIONS: 'true'
+ CARGO_PROFILE_TEST_OVERFLOW_CHECKS: 'true'
+ run: |
+ set -euo pipefail
+ TEST_NAME=preparation::tests::full::real_two_fresh_js_builds
+ TEST_LIST="$(mktemp)"
+ trap 'rm -f "$TEST_LIST"' EXIT
+ cargo test -p trusted-server-release --target "$HOST_TARGET" --locked "$TEST_NAME" -- --ignored --exact --list > "$TEST_LIST"
+ awk '/: test$/ { count++ } END { if (count != 1) { print "Expected exactly one full JS execution fixture"; exit 1 } }' "$TEST_LIST"
+ cargo test -p trusted-server-release --target "$HOST_TARGET" --locked "$TEST_NAME" -- --ignored --exact --nocapture
+ cat "$TS_RELEASE_JS_FIXTURE_ROOT/js-execution-measurement.json"
+
+ - name: Guard and run native JS prebuilt tests
+ shell: bash
+ run: |
+ set -euo pipefail
+ TEST_LIST="$(mktemp)"
+ trap 'rm -f "$TEST_LIST"' EXIT
+ cargo test -p trusted-server-js --target "$HOST_TARGET" --test release_prebuilt -- --list > "$TEST_LIST"
+ awk '/: test$/ { count++ } END { if (count == 0) { print "No native JS prebuilt tests selected"; exit 1 } print "Selected " count " native JS prebuilt tests" }' "$TEST_LIST"
+ cargo test -p trusted-server-js --target "$HOST_TARGET" --test release_prebuilt
+ cargo clippy -p trusted-server-js --target "$HOST_TARGET" --all-targets --all-features -- -D warnings
+
+ # Ordinary CI candidate evidence of the confined Cargo recipe; this job is
+ # not release qualification (every measurement reports it as such).
+ test-release-cargo-candidate:
+ name: cargo test (confined release Cargo candidate, Linux)
+ runs-on: ubuntu-24.04
+ # Fixture preparation, two fresh JS builds (~12 min), then two confined
+ # Fastly builds each with its own vendoring (~3-4 min each) and the
+ # final-byte comparison: 45 minutes left too little headroom. The
+ # uncached Viceroy install (~5-8 min) and three gate runs add to that.
+ timeout-minutes: 75
+ outputs:
+ # CI-internal descriptor of the uploaded candidate entry, for the
+ # candidate index collector; not a release schema.
+ candidate-entry: ${{ steps.describe-entry.outputs.candidate-entry }}
+ env:
+ # Optimize repeated whole-input hashes with test safety checks retained.
+ CARGO_PROFILE_TEST_OPT_LEVEL: '2'
+ CARGO_PROFILE_TEST_DEBUG_ASSERTIONS: 'true'
+ CARGO_PROFILE_TEST_OVERFLOW_CHECKS: 'true'
+ TS_RELEASE_CANDIDATE_ENTRY_UPLOAD: candidate-entry-trusted-server-fastly.wasm-${{ github.run_id }}-${{ github.run_attempt }}
+ steps:
+ - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 (Node 24)
+ with:
+ persist-credentials: false
+ # Full history: the fixtures clone this checkout, and source
+ # authentication rejects a shallow repository (`.git/shallow`).
+ fetch-depth: 0
+
+ - name: Read pinned tool versions
+ id: tools
+ shell: bash
+ run: |
+ set -euo pipefail
+ {
+ echo "rust=$(awk '$1 == "rust" { print $2 }' .tool-versions)"
+ echo "node=$(awk '$1 == "nodejs" { print $2 }' .tool-versions)"
+ echo "viceroy=$(awk '$1 == "viceroy" { print $2 }' .tool-versions)"
+ } >> "$GITHUB_OUTPUT"
+
+ - name: Use pinned Node.js
+ uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0 (Node 24)
+ with:
+ node-version: ${{ steps.tools.outputs.node }}
+ cache: npm
+ cache-dependency-path: crates/trusted-server-js/lib/package-lock.json
+
+ # The runner's installed rustup bootstraps ordinary CI, not the release producer.
+ - name: Install pinned native Rust toolchain with the Wasm target
+ env:
+ RUST_VERSION: ${{ steps.tools.outputs.rust }}
+ shell: bash
+ run: |
+ set -euo pipefail
+ rustup toolchain install "$RUST_VERSION" --profile minimal
+ rustup target add --toolchain "$RUST_VERSION" wasm32-wasip1
+ rustup override set "$RUST_VERSION"
+ HOST_TARGET="$(rustc -vV | sed -n 's/^host: //p')"
+ test -n "$HOST_TARGET"
+ echo "HOST_TARGET=$HOST_TARGET" >> "$GITHUB_ENV"
+
+ # Restores the host release-tool build (third-party rlibs; Cargo still
+ # rebuilds trusted-server-release from the checkout). This calls
+ # actions/cache directly, not ./.github/actions/setup-rust, which the
+ # format.yml guard requires for Rust caching elsewhere: these jobs
+ # install the pinned toolchain with rustup on purpose, and setup-rust
+ # would export RUSTFLAGS and other environment into the release tool.
+ # The guard only rejects setup-rust-toolchain and rust-cache steps.
+ # Only main saves (below), so a pull request restores only main's
+ # entry, never another pull request's. ~/.cargo/registry is never
+ # cached. A restored build stays ordinary CI evidence only: every
+ # measurement records `release_qualification: false` and the key.
+ - name: Restore the release-tool host build cache
+ id: release-tool-cache
+ uses: actions/cache/restore@caa296126883cff596d87d8935842f9db880ef25 # v5.1.0 (Node 24)
+ with:
+ path: target
+ key: release-tool-${{ runner.os }}-${{ env.HOST_TARGET }}-rust-${{ steps.tools.outputs.rust }}-${{ hashFiles('Cargo.lock', 'rust-toolchain.toml', '.tool-versions', 'crates/trusted-server-release/Cargo.toml') }}-test-o${{ env.CARGO_PROFILE_TEST_OPT_LEVEL }}-da${{ env.CARGO_PROFILE_TEST_DEBUG_ASSERTIONS }}-oc${{ env.CARGO_PROFILE_TEST_OVERFLOW_CHECKS }}
+
+ # Same pinned source install as the other Viceroy jobs, but uncached and
+ # into a private root: the runtime gate selects this exact absolute path,
+ # checks its reported version and records its digest.
+ - name: Install pinned Viceroy for the final-byte runtime gate
+ env:
+ VICEROY_VERSION: ${{ steps.tools.outputs.viceroy }}
+ shell: bash
+ run: |
+ set -euo pipefail
+ test -n "$VICEROY_VERSION"
+ VICEROY_ROOT="$RUNNER_TEMP/release-viceroy"
+ cargo install viceroy --version "$VICEROY_VERSION" --locked --root "$VICEROY_ROOT"
+ test "$("$VICEROY_ROOT/bin/viceroy" --version)" = "viceroy $VICEROY_VERSION"
+ echo "TS_RELEASE_VICEROY=$VICEROY_ROOT/bin/viceroy" >> "$GITHUB_ENV"
+
+ - name: Prepare ordinary CI JS and Cargo fixtures
+ shell: bash
+ run: |
+ set -euo pipefail
+ cargo build -p trusted-server-release --target "$HOST_TARGET" --locked
+ # Build the test binary now so the cache below holds its dependencies.
+ cargo test -p trusted-server-release --target "$HOST_TARGET" --locked --no-run
+ PRODUCER="target/$HOST_TARGET/debug/trusted-server-release"
+ JS_FIXTURE_ROOT="$RUNNER_TEMP/release-js-original-fixture"
+ CARGO_FIXTURE_ROOT="$RUNNER_TEMP/release-cargo-fixture"
+ node scripts/release/tests/prepare-js-fixture.mjs "$JS_FIXTURE_ROOT" "$PRODUCER"
+ node scripts/release/tests/prepare-cargo-fixture.mjs "$CARGO_FIXTURE_ROOT" "$PRODUCER"
+ echo "TS_RELEASE_JS_FIXTURE_ROOT=$JS_FIXTURE_ROOT" >> "$GITHUB_ENV"
+ echo "TS_RELEASE_CARGO_FIXTURE_ROOT=$CARGO_FIXTURE_ROOT" >> "$GITHUB_ENV"
+ df -h "$RUNNER_TEMP"
+
+ # Saved before any ignored fixture test runs, so no fixture output can
+ # enter the cache. The Linux CLI and JS handoff jobs restore this same
+ # key without saving it.
+ - name: Drop incremental state before saving the host build cache
+ if: github.ref == 'refs/heads/main' && steps.release-tool-cache.outputs.cache-hit != 'true'
+ shell: bash
+ run: rm -rf target/debug/incremental target/*/debug/incremental
+
+ - name: Save the release-tool host build cache (main only)
+ if: github.ref == 'refs/heads/main' && steps.release-tool-cache.outputs.cache-hit != 'true'
+ uses: actions/cache/save@caa296126883cff596d87d8935842f9db880ef25 # v5.1.0 (Node 24)
+ with:
+ path: target
+ key: ${{ steps.release-tool-cache.outputs.cache-primary-key }}
+
+ - name: Prove Cargo build scripts and proc macros inherit denial (Linux)
+ env:
+ TS_RELEASE_CI_BUILD_CACHE: ${{ steps.release-tool-cache.outputs.cache-matched-key || 'miss' }}
+ shell: bash
+ run: |
+ set -euo pipefail
+ TEST_NAME=recipe::tests::full::cargo_children_inherit_denial
+ TEST_LIST="$(mktemp)"
+ trap 'rm -f "$TEST_LIST"' EXIT
+ cargo test -p trusted-server-release --target "$HOST_TARGET" --locked "$TEST_NAME" -- --ignored --exact --list > "$TEST_LIST"
+ awk '/: test$/ { count++ } END { if (count != 1) { print "Expected exactly one Cargo denial fixture"; exit 1 } }' "$TEST_LIST"
+ cargo test -p trusted-server-release --target "$HOST_TARGET" --locked "$TEST_NAME" -- --ignored --exact --nocapture
+ cat "$TS_RELEASE_CARGO_FIXTURE_ROOT/cargo-denial-measurement.json"
+
+ # Before each build, the producer-normalized Git manifests are compared
+ # with cargo vendor's for dependency resolution (evidence only). The
+ # canonical and reproduction builds use the same JS handoff in
+ # separate fresh roots and boundaries; the final Fastly files must be
+ # byte-identical. Both outputs then pass the final-byte Viceroy runtime
+ # gate, and a closed-origin run must be rejected despite a healthy probe.
+ - name: Build canonical and reproduction Fastly from one JS handoff, compare final bytes and run the Viceroy gate (Linux)
+ env:
+ TS_RELEASE_CI_BUILD_CACHE: ${{ steps.release-tool-cache.outputs.cache-matched-key || 'miss' }}
+ shell: bash
+ run: |
+ set -euo pipefail
+ TEST_NAME=recipe::tests::full::fastly_canonical_and_reproduction_builds_are_byte_identical
+ TEST_LIST="$(mktemp)"
+ trap 'rm -f "$TEST_LIST"' EXIT
+ cargo test -p trusted-server-release --target "$HOST_TARGET" --locked "$TEST_NAME" -- --ignored --exact --list > "$TEST_LIST"
+ awk '/: test$/ { count++ } END { if (count != 1) { print "Expected exactly one Fastly canonical/reproduction equality fixture"; exit 1 } }' "$TEST_LIST"
+ cargo test -p trusted-server-release --target "$HOST_TARGET" --locked "$TEST_NAME" -- --ignored --exact --nocapture
+ cat "$TS_RELEASE_CARGO_FIXTURE_ROOT/normalized-manifest-differential-measurement.json"
+ cat "$TS_RELEASE_CARGO_FIXTURE_ROOT/cargo-canonical-reproduction-equality-measurement.json"
+ cat "$TS_RELEASE_CARGO_FIXTURE_ROOT/fastly-runtime-gate-measurement.json"
+
+ # The rechecked canonical entry and its final executable, as six flat
+ # files, for the candidate index collector. Unauthenticated ordinary CI
+ # evidence; fork pull requests skip it like the transfer fixture.
+ - name: Upload the unqualified Fastly candidate entry for the index collector
+ id: upload-entry
+ if: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository }}
+ uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 (Node 24)
+ with:
+ name: ${{ env.TS_RELEASE_CANDIDATE_ENTRY_UPLOAD }}
+ path: ${{ env.TS_RELEASE_CARGO_FIXTURE_ROOT }}/candidate-entry
+ if-no-files-found: error
+ retention-days: 1
+
+ - name: Describe the uploaded Fastly candidate entry
+ id: describe-entry
+ if: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository }}
+ shell: bash
+ env:
+ TRANSFER_FILE: ${{ env.TS_RELEASE_CARGO_FIXTURE_ROOT }}/candidate-entry-transfer.json
+ ARTIFACT_ID: ${{ steps.upload-entry.outputs.artifact-id }}
+ ARTIFACT_DIGEST: ${{ steps.upload-entry.outputs.artifact-digest }}
+ run: |
+ set -euo pipefail
+ DESCRIPTOR="$(jq -c --argjson id "$ARTIFACT_ID" --arg name "$TS_RELEASE_CANDIDATE_ENTRY_UPLOAD" --arg digest "$ARTIFACT_DIGEST" --argjson attempt "$GITHUB_RUN_ATTEMPT" '. + {artifact_id: $id, artifact_name: $name, archive_sha256: $digest, run_attempt: $attempt}' "$TRANSFER_FILE")"
+ echo "$DESCRIPTOR"
+ echo "candidate-entry=$DESCRIPTOR" >> "$GITHUB_OUTPUT"
+
+ test-release-cli-candidate:
+ name: cargo test (Linux ts CLI release candidate)
+ runs-on: ubuntu-24.04
+ # Fixture preparation (~6 min, including the UBI 9 builder image and the
+ # three startup image pulls), two fresh JS builds (~12 min), then the
+ # canonical and, when measured, the reproduction confined CLI builds
+ # (~10-15 min each, about twice the Fastly dependency closure plus C
+ # code) and three clean-container startups.
+ timeout-minutes: 75
+ outputs:
+ # CI-internal descriptor of the uploaded candidate entry, for the
+ # candidate index collector; not a release schema.
+ candidate-entry: ${{ steps.describe-entry.outputs.candidate-entry }}
+ env:
+ # Optimize repeated whole-input hashes with test safety checks retained.
+ CARGO_PROFILE_TEST_OPT_LEVEL: '2'
+ CARGO_PROFILE_TEST_DEBUG_ASSERTIONS: 'true'
+ CARGO_PROFILE_TEST_OVERFLOW_CHECKS: 'true'
+ TS_RELEASE_CANDIDATE_ENTRY_UPLOAD: candidate-entry-ts-x86_64-unknown-linux-gnu-${{ github.run_id }}-${{ github.run_attempt }}
+ # The reproduction build is a measurement, never a gate. Scheduled and
+ # dispatched runs (and pull requests labelled before the push) measure
+ # it; every other run records it as deferred. The test rejects a
+ # missing value and `defer` on a scheduled or dispatched run.
+ TS_RELEASE_NATIVE_REPRODUCTION: ${{ (github.event_name == 'schedule' || github.event_name == 'workflow_dispatch' || contains(github.event.pull_request.labels.*.name, 'release-native-reproduction')) && 'measure' || 'defer' }}
+ steps:
+ - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 (Node 24)
+ with:
+ persist-credentials: false
+ # Full history: the fixtures clone this checkout, and source
+ # authentication rejects a shallow repository (`.git/shallow`).
+ fetch-depth: 0
+
+ - name: Read pinned tool versions
+ id: tools
+ shell: bash
+ run: |
+ set -euo pipefail
+ {
+ echo "rust=$(awk '$1 == "rust" { print $2 }' .tool-versions)"
+ echo "node=$(awk '$1 == "nodejs" { print $2 }' .tool-versions)"
+ } >> "$GITHUB_OUTPUT"
+
+ - name: Use pinned Node.js
+ uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0 (Node 24)
+ with:
+ node-version: ${{ steps.tools.outputs.node }}
+ cache: npm
+ cache-dependency-path: crates/trusted-server-js/lib/package-lock.json
+
+ # The runner's installed rustup bootstraps ordinary CI, not the release
+ # producer. The copied toolchain keeps its host x86_64-unknown-linux-gnu
+ # standard library, which the CLI targets; the Cargo fixture also
+ # requires the Wasm one.
+ - name: Install pinned native Rust toolchain with the Wasm target
+ env:
+ RUST_VERSION: ${{ steps.tools.outputs.rust }}
+ shell: bash
+ run: |
+ set -euo pipefail
+ rustup toolchain install "$RUST_VERSION" --profile minimal
+ rustup target add --toolchain "$RUST_VERSION" wasm32-wasip1
+ rustup override set "$RUST_VERSION"
+ HOST_TARGET="$(rustc -vV | sed -n 's/^host: //p')"
+ test "$HOST_TARGET" = x86_64-unknown-linux-gnu
+ echo "HOST_TARGET=$HOST_TARGET" >> "$GITHUB_ENV"
+
+ # Restore only: the Cargo candidate job saves this key (see its comment
+ # for why this is actions/cache and what the cache may and may not
+ # hold). The fixture probes here come from the uncached in-container
+ # bootstrap below; only the host test binary uses the restored build.
+ - name: Restore the release-tool host build cache
+ id: release-tool-cache
+ uses: actions/cache/restore@caa296126883cff596d87d8935842f9db880ef25 # v5.1.0 (Node 24)
+ with:
+ path: target
+ key: release-tool-${{ runner.os }}-${{ env.HOST_TARGET }}-rust-${{ steps.tools.outputs.rust }}-${{ hashFiles('Cargo.lock', 'rust-toolchain.toml', '.tool-versions', 'crates/trusted-server-release/Cargo.toml') }}-test-o${{ env.CARGO_PROFILE_TEST_OPT_LEVEL }}-da${{ env.CARGO_PROFILE_TEST_DEBUG_ASSERTIONS }}-oc${{ env.CARGO_PROFILE_TEST_OVERFLOW_CHECKS }}
+
+ - name: Prepare ordinary CI JS, Cargo and CLI fixtures
+ shell: bash
+ run: |
+ set -euo pipefail
+ cargo build -p trusted-server-release --target "$HOST_TARGET" --locked
+ cargo test -p trusted-server-release --target "$HOST_TARGET" --locked --no-run
+ PRODUCER="target/$HOST_TARGET/debug/trusted-server-release"
+ JS_FIXTURE_ROOT="$RUNNER_TEMP/release-js-original-fixture"
+ CARGO_FIXTURE_ROOT="$RUNNER_TEMP/release-cargo-fixture"
+ CLI_FIXTURE_ROOT="$RUNNER_TEMP/release-cli-fixture"
+ node scripts/release/tests/prepare-cli-fixture.mjs "$CLI_FIXTURE_ROOT"
+ # Rebuild the same producer inside the GNU baseline. The Ubuntu
+ # host binary requires a newer libc and cannot serve as its probe.
+ # These cached downloads remain unauthenticated candidate inputs.
+ BOOTSTRAP_ROOT="$(mktemp -d "$RUNNER_TEMP/cli-producer.XXXXXX")"
+ BOOTSTRAP_NAME="ts-cli-producer-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}"
+ BOOTSTRAP_ID=
+ BOOTSTRAP_CREATE_STARTED=false
+ mkdir "$BOOTSTRAP_ROOT/cargo-home" "$BOOTSTRAP_ROOT/target" "$BOOTSTRAP_ROOT/docker-config"
+ cleanup_cli_bootstrap() {
+ local build_status=$?
+ trap - EXIT
+ local reference="$BOOTSTRAP_NAME" filter="name=^/$BOOTSTRAP_NAME$"
+ if [[ -n "$BOOTSTRAP_ID" ]]; then
+ reference="$BOOTSTRAP_ID"
+ filter="id=$BOOTSTRAP_ID"
+ elif [[ "$BOOTSTRAP_CREATE_STARTED" == true ]]; then
+ printf '%s\n' 'Bootstrap creation was not acknowledged; no start was issued.' >&2
+ [[ "$build_status" -ne 0 ]] || build_status=1
+ fi
+ timeout --kill-after=5s 30s docker --config "$BOOTSTRAP_ROOT/docker-config" --host unix:///var/run/docker.sock stop --time 2 "$reference" >/dev/null 2>&1 || true
+ timeout --kill-after=5s 30s docker --config "$BOOTSTRAP_ROOT/docker-config" --host unix:///var/run/docker.sock rm --force "$reference" >/dev/null 2>&1 || true
+ local survivors
+ survivors="$(timeout --kill-after=5s 30s docker --config "$BOOTSTRAP_ROOT/docker-config" --host unix:///var/run/docker.sock ps --all --quiet --filter "$filter")" || exit 1
+ [[ -z "$survivors" ]] || exit 1
+ exit "$build_status"
+ }
+ trap cleanup_cli_bootstrap EXIT
+ cp -R "$HOME/.cargo/registry" "$BOOTSTRAP_ROOT/cargo-home/registry"
+ if [[ -d "$HOME/.cargo/git" ]]; then
+ cp -R "$HOME/.cargo/git" "$BOOTSTRAP_ROOT/cargo-home/git"
+ fi
+ BUILDER_IMAGE="$(node -p 'JSON.parse(require("node:fs").readFileSync(process.argv[1], "utf8")).builder_image_id' "$CLI_FIXTURE_ROOT/cli-fixture.json")"
+ TOOLCHAIN_ROOT="$(rustc --print sysroot)"
+ BOOTSTRAP_CREATE_STARTED=true
+ BOOTSTRAP_CREATED="$(timeout --kill-after=5s 60s docker --config "$BOOTSTRAP_ROOT/docker-config" --host unix:///var/run/docker.sock create \
+ --init --name "$BOOTSTRAP_NAME" --pull=never --network=none --read-only \
+ --user "$(id -u):$(id -g)" --cap-drop=ALL --security-opt=no-new-privileges \
+ --pids-limit=256 --memory=4g --cpus=2 --tmpfs /tmp:rw,nosuid,nodev,size=256m \
+ --mount "type=bind,src=$GITHUB_WORKSPACE,dst=/source,readonly" \
+ --mount "type=bind,src=$TOOLCHAIN_ROOT,dst=/tools,readonly" \
+ --mount "type=bind,src=$BOOTSTRAP_ROOT/cargo-home,dst=/cargo-home" \
+ --mount "type=bind,src=$BOOTSTRAP_ROOT/target,dst=/target" \
+ --workdir /source --env PATH=/tools/bin:/usr/bin:/bin --env HOME=/tmp \
+ --env CARGO_HOME=/cargo-home --env CARGO_TARGET_DIR=/target --env RUSTC=/tools/bin/rustc \
+ --env GIT_CONFIG_NOSYSTEM=1 --env GIT_CONFIG_GLOBAL=/dev/null \
+ --entrypoint /tools/bin/cargo "$BUILDER_IMAGE" \
+ build -p trusted-server-release --target "$HOST_TARGET" --locked --offline)"
+ [[ "$BOOTSTRAP_CREATED" =~ ^[a-f0-9]{64}$ ]]
+ BOOTSTRAP_ID="$BOOTSTRAP_CREATED"
+ timeout --kill-after=10s 15m docker --config "$BOOTSTRAP_ROOT/docker-config" --host unix:///var/run/docker.sock start --attach "$BOOTSTRAP_ID"
+ PRODUCER="$BOOTSTRAP_ROOT/target/$HOST_TARGET/debug/trusted-server-release"
+ node scripts/release/tests/prepare-js-fixture.mjs "$JS_FIXTURE_ROOT" "$PRODUCER"
+ node scripts/release/tests/prepare-cargo-fixture.mjs "$CARGO_FIXTURE_ROOT" "$PRODUCER"
+ {
+ echo "TS_RELEASE_JS_FIXTURE_ROOT=$JS_FIXTURE_ROOT"
+ echo "TS_RELEASE_CARGO_FIXTURE_ROOT=$CARGO_FIXTURE_ROOT"
+ echo "TS_RELEASE_CLI_FIXTURE_ROOT=$CLI_FIXTURE_ROOT"
+ } >> "$GITHUB_ENV"
+ cat "$CLI_FIXTURE_ROOT/cli-fixture.json"
+ df -h "$RUNNER_TEMP"
+
+ - name: Verify startup timeout removes its owned container
+ shell: bash
+ run: |
+ set -euo pipefail
+ TEST_NAME=recipe::startup::tests::startup_timeout_removes_only_its_owned_container
+ TEST_LIST="$(mktemp)"
+ trap 'rm -f "$TEST_LIST"' EXIT
+ cargo test -p trusted-server-release --target "$HOST_TARGET" --locked "$TEST_NAME" -- --ignored --exact --list > "$TEST_LIST"
+ awk '/: test$/ { count++ } END { if (count != 1) { print "Expected exactly one startup cleanup fixture"; exit 1 } }' "$TEST_LIST"
+ cargo test -p trusted-server-release --target "$HOST_TARGET" --locked "$TEST_NAME" -- --ignored --exact --nocapture
+
+ # The canonical build is gated: glibc 2.34 ELF policy with retained
+ # debug info, the remap scan, planted operator values absent and
+ # `ts --version` in clean UBI 9, Ubuntu 22.04 and Debian 12 containers.
+ # The reproduction build is recorded, never a gate, and only built when
+ # TS_RELEASE_NATIVE_REPRODUCTION is `measure`.
+ - name: Build, inspect and start the Linux ts candidate; reproduction per TS_RELEASE_NATIVE_REPRODUCTION (Linux)
+ env:
+ TS_RELEASE_CI_BUILD_CACHE: ${{ steps.release-tool-cache.outputs.cache-matched-key || 'miss' }}
+ shell: bash
+ run: |
+ set -euo pipefail
+ echo "Native reproduction: $TS_RELEASE_NATIVE_REPRODUCTION (event ${GITHUB_EVENT_NAME})"
+ TEST_NAME=recipe::tests::cli::linux_cli_candidate_builds_inspects_and_starts_cleanly
+ TEST_LIST="$(mktemp)"
+ trap 'rm -f "$TEST_LIST"' EXIT
+ cargo test -p trusted-server-release --target "$HOST_TARGET" --locked "$TEST_NAME" -- --ignored --exact --list > "$TEST_LIST"
+ awk '/: test$/ { count++ } END { if (count != 1) { print "Expected exactly one Linux CLI candidate fixture"; exit 1 } }' "$TEST_LIST"
+ cargo test -p trusted-server-release --target "$HOST_TARGET" --locked "$TEST_NAME" -- --ignored --exact --nocapture
+ cat "$TS_RELEASE_CLI_FIXTURE_ROOT/linux-cli-candidate-measurement.json"
+ df -h "$RUNNER_TEMP"
+
+ # The rechecked canonical entry and its final executable, as six flat
+ # files, for the candidate index collector. Unauthenticated ordinary CI
+ # evidence; fork pull requests skip it like the transfer fixture.
+ - name: Upload the unqualified Linux CLI candidate entry for the index collector
+ id: upload-entry
+ if: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository }}
+ uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 (Node 24)
+ with:
+ name: ${{ env.TS_RELEASE_CANDIDATE_ENTRY_UPLOAD }}
+ path: ${{ env.TS_RELEASE_CLI_FIXTURE_ROOT }}/candidate-entry
+ if-no-files-found: error
+ retention-days: 1
+
+ - name: Describe the uploaded Linux CLI candidate entry
+ id: describe-entry
+ if: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository }}
+ shell: bash
+ env:
+ TRANSFER_FILE: ${{ env.TS_RELEASE_CLI_FIXTURE_ROOT }}/candidate-entry-transfer.json
+ ARTIFACT_ID: ${{ steps.upload-entry.outputs.artifact-id }}
+ ARTIFACT_DIGEST: ${{ steps.upload-entry.outputs.artifact-digest }}
+ run: |
+ set -euo pipefail
+ DESCRIPTOR="$(jq -c --argjson id "$ARTIFACT_ID" --arg name "$TS_RELEASE_CANDIDATE_ENTRY_UPLOAD" --arg digest "$ARTIFACT_DIGEST" --argjson attempt "$GITHUB_RUN_ATTEMPT" '. + {artifact_id: $id, artifact_name: $name, archive_sha256: $digest, run_attempt: $attempt}' "$TRANSFER_FILE")"
+ echo "$DESCRIPTOR"
+ echo "candidate-entry=$DESCRIPTOR" >> "$GITHUB_OUTPUT"
+
+ - name: Preserve available CLI candidate measurements in the job log
+ if: always()
+ shell: bash
+ run: |
+ if [[ -n "${TS_RELEASE_CLI_FIXTURE_ROOT:-}" ]]; then
+ for report in builder-filesystem-controls.json linux-cli-candidate-measurement.json; do
+ if [[ -f "$TS_RELEASE_CLI_FIXTURE_ROOT/$report" ]]; then
+ cat "$TS_RELEASE_CLI_FIXTURE_ROOT/$report"
+ fi
+ done
+ fi
+
+ # Measured runs keep the reproduction measurement as an artifact so
+ # reports can cite it, not only the job log.
+ - name: Upload the measured Linux CLI candidate measurement
+ if: ${{ !cancelled() && env.TS_RELEASE_NATIVE_REPRODUCTION == 'measure' }}
+ uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 (Node 24)
+ with:
+ name: linux-cli-candidate-measurement
+ path: ${{ env.TS_RELEASE_CLI_FIXTURE_ROOT }}/linux-cli-candidate-measurement.json
+ if-no-files-found: warn
+ retention-days: 90
+ # A re-run of this job replaces its own earlier upload.
+ overwrite: true
+
+ # Decides whether the macOS candidate jobs and their Linux JS handoff run.
+ # It fails open: any error, a new branch or an unknown event selects them.
+ # Skipped jobs report success. The candidate index collector needs all four
+ # candidate jobs to succeed, so a deselected range also skips it; the step
+ # summary records that reason. The collector is not a required check.
+ changes:
+ name: Detect release candidate changes
+ runs-on: ubuntu-24.04
+ timeout-minutes: 5
+ outputs:
+ release: ${{ steps.filter.outputs.release }}
+ steps:
+ - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 (Node 24)
+ with:
+ persist-credentials: false
+ fetch-depth: 0
+
+ - name: Match changed paths against the release candidate inputs
+ id: filter
+ shell: bash
+ env:
+ EVENT_NAME: ${{ github.event_name }}
+ PULL_BASE_SHA: ${{ github.event.pull_request.base.sha }}
+ PULL_HEAD_SHA: ${{ github.event.pull_request.head.sha }}
+ PUSH_BEFORE_SHA: ${{ github.event.before }}
+ PUSH_AFTER_SHA: ${{ github.sha }}
+ run: |
+ set -euo pipefail
+ node --test scripts/release/tests/candidate-changes.test.mjs
+ select_release() {
+ echo "release=$1" >> "$GITHUB_OUTPUT"
+ echo "Release candidate jobs selected: $1 ($2)"
+ if [[ "$1" != true ]]; then
+ echo "Candidate index collector not selected: the macOS candidates are deselected ($2), and the four-asset candidate index is assembled only when all four candidate jobs succeed." >> "$GITHUB_STEP_SUMMARY"
+ fi
+ exit 0
+ }
+ case "$EVENT_NAME" in
+ pull_request) RANGE="$PULL_BASE_SHA...$PULL_HEAD_SHA" ;;
+ push) RANGE="$PUSH_BEFORE_SHA..$PUSH_AFTER_SHA" ;;
+ *) select_release true "event $EVENT_NAME" ;;
+ esac
+ if [[ "$RANGE" == *0000000000000000000000000000000000000000* || "$RANGE" == ...* || "$RANGE" == *... || "$RANGE" == ..* ]]; then
+ select_release true "no comparable base"
+ fi
+ if ! SELECTED="$(bash scripts/release/tests/candidate-changes.sh "$RANGE")"; then
+ select_release true "cannot classify $RANGE"
+ fi
+ case "$SELECTED" in
+ true|false) select_release "$SELECTED" "comparison $RANGE" ;;
+ *) select_release true "unexpected classifier result" ;;
+ esac
+
+ # Exports the Linux two-fresh JS handoff for the macOS candidate jobs,
+ # which cannot run the Linux-only JS build. The artifact is unauthenticated
+ # ordinary CI evidence; the adopting job labels it so.
+ test-release-js-handoff:
+ name: cargo test (export Linux JS handoff for macOS candidates)
+ needs: changes
+ if: ${{ !cancelled() && (needs.changes.result != 'success' || needs.changes.outputs.release == 'true') }}
+ runs-on: ubuntu-24.04
+ # Fixture preparation (~3 min, including the JS fixture image) and the
+ # two fresh JS builds (~12 min).
+ timeout-minutes: 30
+ env:
+ # Optimize repeated whole-input hashes with test safety checks retained.
+ CARGO_PROFILE_TEST_OPT_LEVEL: '2'
+ CARGO_PROFILE_TEST_DEBUG_ASSERTIONS: 'true'
+ CARGO_PROFILE_TEST_OVERFLOW_CHECKS: 'true'
+ steps:
+ - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 (Node 24)
+ with:
+ persist-credentials: false
+ # Full history: the fixture clones this checkout, and source
+ # authentication rejects a shallow repository (`.git/shallow`).
+ fetch-depth: 0
+
+ - name: Read pinned tool versions
+ id: tools
+ shell: bash
+ run: |
+ set -euo pipefail
+ {
+ echo "rust=$(awk '$1 == "rust" { print $2 }' .tool-versions)"
+ echo "node=$(awk '$1 == "nodejs" { print $2 }' .tool-versions)"
+ } >> "$GITHUB_OUTPUT"
+
+ - name: Use pinned Node.js
+ uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0 (Node 24)
+ with:
+ node-version: ${{ steps.tools.outputs.node }}
+ cache: npm
+ cache-dependency-path: crates/trusted-server-js/lib/package-lock.json
+
+ # The runner's installed rustup bootstraps ordinary CI, not the release producer.
+ - name: Install pinned native Rust toolchain
+ env:
+ RUST_VERSION: ${{ steps.tools.outputs.rust }}
+ shell: bash
+ run: |
+ set -euo pipefail
+ rustup toolchain install "$RUST_VERSION" --profile minimal
+ rustup override set "$RUST_VERSION"
+ HOST_TARGET="$(rustc -vV | sed -n 's/^host: //p')"
+ test "$HOST_TARGET" = x86_64-unknown-linux-gnu
+ echo "HOST_TARGET=$HOST_TARGET" >> "$GITHUB_ENV"
+
+ # Restore only: the Cargo candidate job saves this key (see its comment
+ # for why this is actions/cache and what the cache may and may not
+ # hold). The Wasm target it also installs does not change host output.
+ - name: Restore the release-tool host build cache
+ id: release-tool-cache
+ uses: actions/cache/restore@caa296126883cff596d87d8935842f9db880ef25 # v5.1.0 (Node 24)
+ with:
+ path: target
+ key: release-tool-${{ runner.os }}-${{ env.HOST_TARGET }}-rust-${{ steps.tools.outputs.rust }}-${{ hashFiles('Cargo.lock', 'rust-toolchain.toml', '.tool-versions', 'crates/trusted-server-release/Cargo.toml') }}-test-o${{ env.CARGO_PROFILE_TEST_OPT_LEVEL }}-da${{ env.CARGO_PROFILE_TEST_DEBUG_ASSERTIONS }}-oc${{ env.CARGO_PROFILE_TEST_OVERFLOW_CHECKS }}
+
+ - name: Prepare original-only full JS fixture
+ shell: bash
+ run: |
+ set -euo pipefail
+ cargo build -p trusted-server-release --target "$HOST_TARGET" --locked
+ cargo test -p trusted-server-release --target "$HOST_TARGET" --locked --no-run
+ FIXTURE_ROOT="$RUNNER_TEMP/release-js-original-fixture"
+ EXPORT_ROOT="$RUNNER_TEMP/release-js-handoff"
+ test ! -e "$EXPORT_ROOT"
+ node scripts/release/tests/prepare-js-fixture.mjs "$FIXTURE_ROOT" "target/$HOST_TARGET/debug/trusted-server-release"
+ {
+ echo "TS_RELEASE_JS_FIXTURE_ROOT=$FIXTURE_ROOT"
+ echo "TS_RELEASE_JS_HANDOFF_EXPORT=$EXPORT_ROOT"
+ } >> "$GITHUB_ENV"
+
+ - name: Run two fresh offline JS builds and export the retained handoff
+ shell: bash
+ run: |
+ set -euo pipefail
+ TEST_NAME=preparation::tests::full::export_js_handoff_for_candidates
+ TEST_LIST="$(mktemp)"
+ trap 'rm -f "$TEST_LIST"' EXIT
+ cargo test -p trusted-server-release --target "$HOST_TARGET" --locked "$TEST_NAME" -- --ignored --exact --list > "$TEST_LIST"
+ awk '/: test$/ { count++ } END { if (count != 1) { print "Expected exactly one JS handoff export fixture"; exit 1 } }' "$TEST_LIST"
+ cargo test -p trusted-server-release --target "$HOST_TARGET" --locked "$TEST_NAME" -- --ignored --exact --nocapture
+ find "$TS_RELEASE_JS_HANDOFF_EXPORT" -type f | sort
+
+ - name: Upload the unauthenticated JS handoff for the macOS candidates
+ uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 (Node 24)
+ with:
+ name: release-js-handoff
+ path: ${{ env.TS_RELEASE_JS_HANDOFF_EXPORT }}
+ if-no-files-found: error
+ retention-days: 1
+ # A re-run of this job replaces its own earlier upload.
+ overwrite: true
+
+ # Ordinary CI candidate evidence of the Darwin ts on native arm64 and Intel
+ # macOS 15; not release qualification (every measurement reports it). The
+ # unqualified sandbox only reaps descendants after each run, the Xcode is
+ # the runner's, and the JS handoff is the Linux job's unauthenticated
+ # artifact.
+ test-release-macos-cli-candidate:
+ name: cargo test (macOS ts CLI release candidate, ${{ matrix.os }})
+ needs: test-release-js-handoff
+ if: ${{ !cancelled() && needs.test-release-js-handoff.result == 'success' }}
+ strategy:
+ fail-fast: false
+ matrix:
+ os: [macos-15, macos-15-intel]
+ runs-on: ${{ matrix.os }}
+ # Setup, fixtures and the producer build (~16 min), four denial builds
+ # (~10 min), then the canonical and, when measured, the reproduction
+ # confined CLI builds and one native startup. On macos-15-intel the
+ # canonical build alone measured 24 min and the two-job reproduction is
+ # slower, so a 90-minute limit cancelled run 39529002302
+ # mid-reproduction.
+ timeout-minutes: 150
+ outputs:
+ # Per-leg keys: each leg sets only its own, so the two descriptors of
+ # the CI-internal candidate-entry upload stay apart in matrix outputs.
+ macos-15-candidate-entry: ${{ steps.describe-entry.outputs.macos-15-candidate-entry }}
+ macos-15-intel-candidate-entry: ${{ steps.describe-entry.outputs.macos-15-intel-candidate-entry }}
+ env:
+ # Optimize repeated whole-input hashes with test safety checks retained.
+ CARGO_PROFILE_TEST_OPT_LEVEL: '2'
+ CARGO_PROFILE_TEST_DEBUG_ASSERTIONS: 'true'
+ CARGO_PROFILE_TEST_OVERFLOW_CHECKS: 'true'
+ TS_RELEASE_CANDIDATE_ENTRY_UPLOAD: candidate-entry-${{ matrix.os == 'macos-15' && 'ts-aarch64-apple-darwin' || 'ts-x86_64-apple-darwin' }}-${{ github.run_id }}-${{ github.run_attempt }}
+ # The reproduction build is a measurement, never a gate. Scheduled and
+ # dispatched runs (and pull requests labelled before the push) measure
+ # it; every other run records it as deferred. The test rejects a
+ # missing value and `defer` on a scheduled or dispatched run.
+ TS_RELEASE_NATIVE_REPRODUCTION: ${{ (github.event_name == 'schedule' || github.event_name == 'workflow_dispatch' || contains(github.event.pull_request.labels.*.name, 'release-native-reproduction')) && 'measure' || 'defer' }}
+ steps:
+ - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 (Node 24)
+ with:
+ persist-credentials: false
+ # Full history: the fixture clones this checkout, and source
+ # authentication rejects a shallow repository (`.git/shallow`).
+ fetch-depth: 0
+
+ - name: Read pinned tool versions
+ id: tools
+ shell: bash
+ run: |
+ set -euo pipefail
+ {
+ echo "rust=$(awk '$1 == "rust" { print $2 }' .tool-versions)"
+ echo "node=$(awk '$1 == "nodejs" { print $2 }' .tool-versions)"
+ } >> "$GITHUB_OUTPUT"
+
+ - name: Use pinned Node.js
+ uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0 (Node 24)
+ with:
+ node-version: ${{ steps.tools.outputs.node }}
+
+ # The runner's installed rustup bootstraps ordinary CI, not the release
+ # producer. The copied toolchain keeps only its host Darwin standard
+ # library, which both the CLI and the denial fixture target.
+ - name: Install pinned native Rust toolchain and check the native host
+ env:
+ RUST_VERSION: ${{ steps.tools.outputs.rust }}
+ shell: bash
+ run: |
+ set -euo pipefail
+ rustup toolchain install "$RUST_VERSION" --profile minimal
+ rustup override set "$RUST_VERSION"
+ HOST_TARGET="$(rustc -vV | sed -n 's/^host: //p')"
+ case "$(uname -m)" in
+ arm64) EXPECTED_TARGET=aarch64-apple-darwin ;;
+ x86_64) EXPECTED_TARGET=x86_64-apple-darwin ;;
+ *) echo "Unsupported macOS machine $(uname -m)" >&2; exit 1 ;;
+ esac
+ test "$HOST_TARGET" = "$EXPECTED_TARGET"
+ # Absent on Intel hardware; 1 would mean a Rosetta-translated shell.
+ TRANSLATED="$(sysctl -n sysctl.proc_translated 2>/dev/null || echo absent)"
+ echo "sysctl.proc_translated=$TRANSLATED"
+ test "$TRANSLATED" != 1
+ echo "Selected Xcode developer directory: $(xcode-select -p)"
+ echo "HOST_TARGET=$HOST_TARGET" >> "$GITHUB_ENV"
+
+ # Each matrix entry restores and (on main) saves its own host target's
+ # key. See the Cargo candidate job for why this is actions/cache and
+ # what the cache may and may not hold.
+ - name: Restore the release-tool host build cache
+ id: release-tool-cache
+ uses: actions/cache/restore@caa296126883cff596d87d8935842f9db880ef25 # v5.1.0 (Node 24)
+ with:
+ path: target
+ key: release-tool-${{ runner.os }}-${{ env.HOST_TARGET }}-rust-${{ steps.tools.outputs.rust }}-${{ hashFiles('Cargo.lock', 'rust-toolchain.toml', '.tool-versions', 'crates/trusted-server-release/Cargo.toml') }}-test-o${{ env.CARGO_PROFILE_TEST_OPT_LEVEL }}-da${{ env.CARGO_PROFILE_TEST_DEBUG_ASSERTIONS }}-oc${{ env.CARGO_PROFILE_TEST_OVERFLOW_CHECKS }}
+
+ - name: Prepare ordinary CI Darwin Cargo fixture
+ shell: bash
+ run: |
+ set -euo pipefail
+ cargo build -p trusted-server-release --target "$HOST_TARGET" --locked
+ # Build the test binary now so the cache below holds its dependencies.
+ cargo test -p trusted-server-release --target "$HOST_TARGET" --locked --no-run
+ CARGO_FIXTURE_ROOT="$RUNNER_TEMP/release-cargo-fixture"
+ JS_HANDOFF_ROOT="$RUNNER_TEMP/release-js-handoff"
+ test ! -e "$JS_HANDOFF_ROOT"
+ node scripts/release/tests/prepare-cargo-fixture.mjs "$CARGO_FIXTURE_ROOT" "target/$HOST_TARGET/debug/trusted-server-release"
+ {
+ echo "TS_RELEASE_CARGO_FIXTURE_ROOT=$CARGO_FIXTURE_ROOT"
+ echo "TS_RELEASE_JS_HANDOFF_ROOT=$JS_HANDOFF_ROOT"
+ } >> "$GITHUB_ENV"
+ df -h "$RUNNER_TEMP"
+
+ # Saved before any ignored fixture test runs, so no fixture output can
+ # enter the cache.
+ - name: Drop incremental state before saving the host build cache
+ if: github.ref == 'refs/heads/main' && steps.release-tool-cache.outputs.cache-hit != 'true'
+ shell: bash
+ run: rm -rf target/debug/incremental target/*/debug/incremental
+
+ - name: Save the release-tool host build cache (main only)
+ if: github.ref == 'refs/heads/main' && steps.release-tool-cache.outputs.cache-hit != 'true'
+ uses: actions/cache/save@caa296126883cff596d87d8935842f9db880ef25 # v5.1.0 (Node 24)
+ with:
+ path: target
+ key: ${{ steps.release-tool-cache.outputs.cache-primary-key }}
+
+ - name: Download the Linux JS handoff into a fresh directory
+ uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0 (Node 24)
+ with:
+ name: release-js-handoff
+ path: ${{ env.TS_RELEASE_JS_HANDOFF_ROOT }}
+
+ - name: Prove Cargo build scripts and proc macros inherit denial (macOS)
+ env:
+ TS_RELEASE_CI_BUILD_CACHE: ${{ steps.release-tool-cache.outputs.cache-matched-key || 'miss' }}
+ shell: bash
+ run: |
+ set -euo pipefail
+ TEST_NAME=recipe::tests::full_macos::cargo_children_inherit_denial_macos
+ TEST_LIST="$(mktemp)"
+ trap 'rm -f "$TEST_LIST"' EXIT
+ cargo test -p trusted-server-release --target "$HOST_TARGET" --locked "$TEST_NAME" -- --ignored --exact --list > "$TEST_LIST"
+ awk '/: test$/ { count++ } END { if (count != 1) { print "Expected exactly one macOS Cargo denial fixture"; exit 1 } }' "$TEST_LIST"
+ cargo test -p trusted-server-release --target "$HOST_TARGET" --locked "$TEST_NAME" -- --ignored --exact --nocapture
+ cat "$TS_RELEASE_CARGO_FIXTURE_ROOT/cargo-denial-measurement.json"
+
+ # The canonical build is gated: Mach-O acceptance with the measured SDK
+ # and a relative debug map, the remap and final-path scans, planted
+ # operator values absent and a native `ts --version` on the reviewed
+ # host. The reproduction build is recorded, never a gate, and only built
+ # when TS_RELEASE_NATIVE_REPRODUCTION is `measure`.
+ - name: Build, inspect and start the macOS ts candidate; reproduction per TS_RELEASE_NATIVE_REPRODUCTION (macOS)
+ env:
+ TS_RELEASE_CI_BUILD_CACHE: ${{ steps.release-tool-cache.outputs.cache-matched-key || 'miss' }}
+ shell: bash
+ run: |
+ set -euo pipefail
+ echo "Native reproduction: $TS_RELEASE_NATIVE_REPRODUCTION (event ${GITHUB_EVENT_NAME})"
+ TEST_NAME=recipe::tests::macos_cli::macos_cli_candidate_builds_inspects_and_starts_cleanly
+ TEST_LIST="$(mktemp)"
+ trap 'rm -f "$TEST_LIST"' EXIT
+ cargo test -p trusted-server-release --target "$HOST_TARGET" --locked "$TEST_NAME" -- --ignored --exact --list > "$TEST_LIST"
+ awk '/: test$/ { count++ } END { if (count != 1) { print "Expected exactly one macOS CLI candidate fixture"; exit 1 } }' "$TEST_LIST"
+ cargo test -p trusted-server-release --target "$HOST_TARGET" --locked "$TEST_NAME" -- --ignored --exact --nocapture
+ cat "$TS_RELEASE_CARGO_FIXTURE_ROOT/macos-cli-candidate-measurement.json"
+ df -h "$RUNNER_TEMP"
+
+ # The rechecked canonical entry and its final executable, as six flat
+ # files, for the candidate index collector. Unauthenticated ordinary CI
+ # evidence; fork pull requests skip it like the transfer fixture.
+ - name: Upload the unqualified macOS CLI candidate entry for the index collector
+ id: upload-entry
+ if: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository }}
+ uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 (Node 24)
+ with:
+ name: ${{ env.TS_RELEASE_CANDIDATE_ENTRY_UPLOAD }}
+ path: ${{ env.TS_RELEASE_CARGO_FIXTURE_ROOT }}/candidate-entry
+ if-no-files-found: error
+ retention-days: 1
+
+ - name: Describe the uploaded macOS CLI candidate entry
+ id: describe-entry
+ if: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository }}
+ shell: bash
+ env:
+ TRANSFER_FILE: ${{ env.TS_RELEASE_CARGO_FIXTURE_ROOT }}/candidate-entry-transfer.json
+ ARTIFACT_ID: ${{ steps.upload-entry.outputs.artifact-id }}
+ ARTIFACT_DIGEST: ${{ steps.upload-entry.outputs.artifact-digest }}
+ MATRIX_OS: ${{ matrix.os }}
+ run: |
+ set -euo pipefail
+ DESCRIPTOR="$(jq -c --argjson id "$ARTIFACT_ID" --arg name "$TS_RELEASE_CANDIDATE_ENTRY_UPLOAD" --arg digest "$ARTIFACT_DIGEST" --argjson attempt "$GITHUB_RUN_ATTEMPT" '. + {artifact_id: $id, artifact_name: $name, archive_sha256: $digest, run_attempt: $attempt}' "$TRANSFER_FILE")"
+ echo "$DESCRIPTOR"
+ echo "${MATRIX_OS}-candidate-entry=$DESCRIPTOR" >> "$GITHUB_OUTPUT"
+
+ - name: Preserve available macOS candidate measurements in the job log
+ if: always()
+ shell: bash
+ run: |
+ if [[ -n "${TS_RELEASE_CARGO_FIXTURE_ROOT:-}" ]]; then
+ for report in cargo-denial-measurement.json macos-cli-candidate-measurement.json; do
+ if [[ -f "$TS_RELEASE_CARGO_FIXTURE_ROOT/$report" ]]; then
+ cat "$TS_RELEASE_CARGO_FIXTURE_ROOT/$report"
+ fi
+ done
+ fi
+
+ # Measured runs keep the reproduction measurement as an artifact so
+ # reports can cite it, not only the job log.
+ - name: Upload the measured macOS CLI candidate measurement
+ if: ${{ !cancelled() && env.TS_RELEASE_NATIVE_REPRODUCTION == 'measure' }}
+ uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 (Node 24)
+ with:
+ name: macos-cli-candidate-measurement-${{ matrix.os }}
+ path: ${{ env.TS_RELEASE_CARGO_FIXTURE_ROOT }}/macos-cli-candidate-measurement.json
+ if-no-files-found: warn
+ retention-days: 90
+ # A re-run of this job replaces its own earlier upload.
+ overwrite: true
+
+ # Bounded: two minutes of wall time and the last 400 lines at most.
+ - name: Show recent sandbox denials on failure
+ if: failure()
+ shell: bash
+ run: |
+ perl -e 'alarm 120; exec @ARGV or die "cannot run log: $!"' \
+ /usr/bin/log show --last 15m --style compact --predicate 'sender=="Sandbox"' \
+ | tail -n 400 || true
+
+ # Joins the four real candidate entries into one unqualified candidate
+ # index. It downloads each candidate job's exact upload through the bounded
+ # release-tool backend, rebuilds every entry from its bytes, derives the
+ # policy from a source-checked clean clone, and runs the CLI consumer's
+ # unsigned joins. Ordinary CI evidence only: no signing, publication,
+ # trusted workflow binding or qualification. It runs only when all four
+ # candidate jobs succeeded (the macOS matrix result covers both legs), so it
+ # never assembles from fewer than four entries. It is not a required check.
+ test-release-candidate-index:
+ name: Assemble the unqualified four-asset candidate index
+ needs:
+ - test-release-cargo-candidate
+ - test-release-cli-candidate
+ - test-release-macos-cli-candidate
+ if: ${{ !cancelled() && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository) && needs.test-release-cargo-candidate.result == 'success' && needs.test-release-cli-candidate.result == 'success' && needs.test-release-macos-cli-candidate.result == 'success' }}
+ runs-on: ubuntu-24.04
+ # Release-tool test build (1-3 min with a warm cache), the CLI library
+ # test build (3-5 min; its dependencies are outside the release-tool
+ # cache, and the embedded JS needs npm), then four downloads, the largest
+ # being the ~285 MB Linux CLI with debug info.
+ timeout-minutes: 45
+ permissions:
+ contents: read
+ actions: read
+ env:
+ # Same profile as the candidate jobs, so the restored cache key matches.
+ CARGO_PROFILE_TEST_OPT_LEVEL: '2'
+ CARGO_PROFILE_TEST_DEBUG_ASSERTIONS: 'true'
+ CARGO_PROFILE_TEST_OVERFLOW_CHECKS: 'true'
+ steps:
+ - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 (Node 24)
+ with:
+ persist-credentials: false
+ # Full history: the retained clone below must not be shallow.
+ fetch-depth: 0
+
+ - name: Read pinned tool versions
+ id: tools
+ shell: bash
+ run: |
+ set -euo pipefail
+ {
+ echo "rust=$(awk '$1 == "rust" { print $2 }' .tool-versions)"
+ echo "node=$(awk '$1 == "nodejs" { print $2 }' .tool-versions)"
+ } >> "$GITHUB_OUTPUT"
+
+ - name: Use pinned Node.js
+ uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0 (Node 24)
+ with:
+ node-version: ${{ steps.tools.outputs.node }}
+ cache: npm
+ cache-dependency-path: crates/trusted-server-js/lib/package-lock.json
+
+ - name: Install pinned native Rust toolchain
+ env:
+ RUST_VERSION: ${{ steps.tools.outputs.rust }}
+ shell: bash
+ run: |
+ set -euo pipefail
+ rustup toolchain install "$RUST_VERSION" --profile minimal
+ rustup override set "$RUST_VERSION"
+ HOST_TARGET="$(rustc -vV | sed -n 's/^host: //p')"
+ test "$HOST_TARGET" = x86_64-unknown-linux-gnu
+ echo "HOST_TARGET=$HOST_TARGET" >> "$GITHUB_ENV"
+
+ # Restore only: the Cargo candidate job saves this key (see its comment
+ # for why this is actions/cache and what the cache may and may not hold).
+ - name: Restore the release-tool host build cache
+ uses: actions/cache/restore@caa296126883cff596d87d8935842f9db880ef25 # v5.1.0 (Node 24)
+ with:
+ path: target
+ key: release-tool-${{ runner.os }}-${{ env.HOST_TARGET }}-rust-${{ steps.tools.outputs.rust }}-${{ hashFiles('Cargo.lock', 'rust-toolchain.toml', '.tool-versions', 'crates/trusted-server-release/Cargo.toml') }}-test-o${{ env.CARGO_PROFILE_TEST_OPT_LEVEL }}-da${{ env.CARGO_PROFILE_TEST_DEBUG_ASSERTIONS }}-oc${{ env.CARGO_PROFILE_TEST_OVERFLOW_CHECKS }}
+
+ # Source authentication rejects untracked paths such as this checkout's
+ # target/ and node_modules/, so the policy is read from a fresh clone of
+ # the checked-out commit with the minimal Git configuration the
+ # fixtures use.
+ - name: Retain a clean clone of the candidate commit
+ shell: bash
+ run: |
+ set -euo pipefail
+ SOURCE_ROOT="$RUNNER_TEMP/release-candidate-source"
+ INDEX_ROOT="$RUNNER_TEMP/release-candidate-index"
+ test ! -e "$SOURCE_ROOT"
+ test ! -e "$INDEX_ROOT"
+ git clone --no-hardlinks --no-checkout -- "$GITHUB_WORKSPACE" "$SOURCE_ROOT"
+ git -C "$SOURCE_ROOT" checkout --detach --force HEAD
+ printf '[core]\nrepositoryformatversion = 0\nbare = false\nfilemode = true\n' > "$SOURCE_ROOT/.git/config"
+ test "$(git -C "$SOURCE_ROOT" rev-parse HEAD)" = "$GITHUB_SHA"
+ {
+ echo "TS_RELEASE_CANDIDATE_SOURCE_ROOT=$SOURCE_ROOT"
+ echo "TS_RELEASE_CANDIDATE_INDEX_ROOT=$INDEX_ROOT"
+ } >> "$GITHUB_ENV"
+
+ - name: Compile and require exactly one collector and one consumer fixture
+ shell: bash
+ run: |
+ set -euo pipefail
+ TEST_LIST="$(mktemp)"
+ trap 'rm -f "$TEST_LIST"' EXIT
+ cargo test -p trusted-server-release --target "$HOST_TARGET" --locked distribution::ordinary::real_four_job_candidate_index -- --ignored --exact --list > "$TEST_LIST"
+ awk '/: test$/ { count++ } END { if (count != 1) { print "Expected exactly one candidate index collector"; exit 1 } }' "$TEST_LIST"
+ cargo test -p trusted-server-cli --target "$HOST_TARGET" --locked --lib release::evidence::candidate_fixture_tests::ordinary_ci_assembled_candidate_passes_unsigned_consumer_joins -- --ignored --exact --list > "$TEST_LIST"
+ awk '/: test$/ { count++ } END { if (count != 1) { print "Expected exactly one candidate index consumer check"; exit 1 } }' "$TEST_LIST"
+
+ - name: Download, rebuild and assemble the four real candidate entries
+ shell: bash
+ env:
+ # The read-only token (contents/actions read) is visible to this
+ # same-repository test binary, which needs it to download; build
+ # scripts already ran without it in the listing step above, and fork
+ # PRs never reach this job.
+ GH_TOKEN: ${{ github.token }}
+ TS_RELEASE_TRANSFER_RUN_ID: ${{ github.run_id }}
+ # REST identifies the PR head; the checkout and every candidate
+ # build use its synthetic merge, which is the candidate source.
+ # Neither identity grants approved-source or release authority here.
+ TS_RELEASE_TRANSFER_RUN_HEAD_SHA: ${{ github.event.pull_request.head.sha || github.sha }}
+ TS_RELEASE_CANDIDATE_SOURCE_COMMIT: ${{ github.sha }}
+ TS_RELEASE_CANDIDATE_ENTRY_FASTLY: ${{ needs.test-release-cargo-candidate.outputs.candidate-entry }}
+ TS_RELEASE_CANDIDATE_ENTRY_LINUX: ${{ needs.test-release-cli-candidate.outputs.candidate-entry }}
+ TS_RELEASE_CANDIDATE_ENTRY_MACOS_ARM: ${{ needs.test-release-macos-cli-candidate.outputs.macos-15-candidate-entry }}
+ TS_RELEASE_CANDIDATE_ENTRY_MACOS_INTEL: ${{ needs.test-release-macos-cli-candidate.outputs.macos-15-intel-candidate-entry }}
+ run: |
+ set -euo pipefail
+ cargo test -p trusted-server-release --target "$HOST_TARGET" --locked distribution::ordinary::real_four_job_candidate_index -- --ignored --exact --nocapture
+
+ - name: Run the consumer's unsigned joins over the assembled index
+ shell: bash
+ env:
+ TS_RELEASE_CANDIDATE_SOURCE_COMMIT: ${{ github.sha }}
+ run: |
+ set -euo pipefail
+ cargo test -p trusted-server-cli --target "$HOST_TARGET" --locked --lib release::evidence::candidate_fixture_tests::ordinary_ci_assembled_candidate_passes_unsigned_consumer_joins -- --ignored --exact --nocapture
+
+ - name: Print the unqualified candidate index and its SHA-256
+ shell: bash
+ run: |
+ set -euo pipefail
+ cat "$TS_RELEASE_CANDIDATE_INDEX_ROOT/candidate-release-index.json"
+ echo
+ (cd "$TS_RELEASE_CANDIDATE_INDEX_ROOT" && sha256sum -- *)
+
+ # Short-retention citable evidence of an unqualified, unsigned candidate;
+ # never a release asset.
+ - name: Upload the assembled unqualified candidate index evidence
+ uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 (Node 24)
+ with:
+ name: unqualified-candidate-release-index-evidence-${{ github.run_id }}-${{ github.run_attempt }}
+ path: ${{ env.TS_RELEASE_CANDIDATE_INDEX_ROOT }}
+ if-no-files-found: error
+ retention-days: 7
+
test-typescript:
name: vitest
runs-on: ubuntu-latest
@@ -263,19 +1320,21 @@ jobs:
run:
working-directory: crates/trusted-server-js/lib
steps:
- - uses: actions/checkout@v4
+ - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 (Node 24)
+ with:
+ persist-credentials: false
- name: Retrieve Node.js version
id: node-version
working-directory: .
- run: echo "node-version=$(grep '^nodejs ' .tool-versions | awk '{print $2}')" >> $GITHUB_OUTPUT
+ run: echo "node-version=$(grep '^nodejs ' .tool-versions | awk '{print $2}')" >> "$GITHUB_OUTPUT"
shell: bash
- name: Use Node.js
- uses: actions/setup-node@v4
+ uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0 (Node 24)
with:
node-version: ${{ steps.node-version.outputs.node-version }}
- cache: "npm"
+ cache: 'npm'
cache-dependency-path: crates/trusted-server-js/lib/package.json
- name: Install dependencies
@@ -285,4 +1344,120 @@ jobs:
run: npm run build
- name: Run unit tests
+ env:
+ TRUSTED_SERVER__RELEASE_JS_TESTS: '1'
run: npm test -- --run
+
+ # Four inert files exercise actual upload/REST/original-ZIP interoperability.
+ # This is ordinary CI diagnostics and supplies no release qualification.
+ test-release-transfer-upload:
+ name: Upload fictional release transfer fixture
+ if: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository }}
+ runs-on: ubuntu-latest
+ timeout-minutes: 15
+ permissions:
+ contents: read
+ outputs:
+ artifact-id: ${{ steps.upload.outputs.artifact-id }}
+ artifact-digest: ${{ steps.upload.outputs.artifact-digest }}
+ artifact-name: ${{ steps.fixture.outputs.name }}
+ steps:
+ - name: Create four fixed fictional members
+ id: fixture
+ shell: bash
+ env:
+ FIXTURE_NAME: release-transfer-interop-${{ github.run_id }}-${{ github.run_attempt }}
+ run: |
+ set -euo pipefail
+ FIXTURE_DIRECTORY="$RUNNER_TEMP/release-transfer-fixture"
+ mkdir "$FIXTURE_DIRECTORY"
+ printf '%s\n' 'fictional transfer executable bytes' > "$FIXTURE_DIRECTORY/artifact"
+ printf '%s\n' '{"kind":"fictional-build-record","release_qualification":false}' > "$FIXTURE_DIRECTORY/build-record.json"
+ printf '%s\n' '{"kind":"fictional-composition","complete":false}' > "$FIXTURE_DIRECTORY/composition.json"
+ printf '%s\n' '{"kind":"fictional-javascript-inventory","modules":[]}' > "$FIXTURE_DIRECTORY/tsjs-inventory.json"
+ echo "name=$FIXTURE_NAME" >> "$GITHUB_OUTPUT"
+
+ - name: Upload the fictional archive with the selected action
+ id: upload
+ uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 (Node 24)
+ with:
+ name: ${{ steps.fixture.outputs.name }}
+ path: ${{ runner.temp }}/release-transfer-fixture
+ if-no-files-found: error
+ retention-days: 1
+
+ test-release-transfer-receive:
+ name: Retrieve exact ordinary-CI upload through bounded HTTP
+ needs: test-release-transfer-upload
+ if: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository }}
+ runs-on: ubuntu-latest
+ timeout-minutes: 15
+ permissions:
+ contents: read
+ actions: read
+ steps:
+ - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 (Node 24)
+ with:
+ persist-credentials: false
+
+ - name: Install the source-pinned Rust toolchain
+ shell: bash
+ run: |
+ set -euo pipefail
+ RUST_VERSION="$(awk '$1 == "rust" { print $2 }' .tool-versions)"
+ test -n "$RUST_VERSION"
+ rustup toolchain install "$RUST_VERSION" --profile minimal
+ HOST_TARGET="$(rustup run "$RUST_VERSION" rustc -vV | sed -n 's/^host: //p')"
+ test -n "$HOST_TARGET"
+ {
+ echo "RUST_VERSION=$RUST_VERSION"
+ echo "HOST_TARGET=$HOST_TARGET"
+ } >> "$GITHUB_ENV"
+
+ - name: Compile and require exactly one ignored interoperability fixture
+ shell: bash
+ run: |
+ set -euo pipefail
+ TEST_NAME=collection::transfer::ordinary::real_uploaded_archive
+ TEST_LIST="$(mktemp)"
+ trap 'rm -f "$TEST_LIST"' EXIT
+ cargo +"$RUST_VERSION" test -p trusted-server-release --target "$HOST_TARGET" --locked "$TEST_NAME" -- --ignored --exact --list > "$TEST_LIST"
+ awk '/: test$/ { count++ } END { if (count != 1) { print "Expected exactly one ordinary transfer fixture"; exit 1 } }' "$TEST_LIST"
+
+ - name: Download and check the exact selected original archive twice
+ shell: bash
+ env:
+ GH_TOKEN: ${{ github.token }}
+ TS_RELEASE_TRANSFER_RUN_ID: ${{ github.run_id }}
+ # REST identifies the PR head; checkout may hold its synthetic merge.
+ # Neither identity grants approved-source or release authority here.
+ TS_RELEASE_TRANSFER_RUN_HEAD_SHA: ${{ github.event.pull_request.head.sha || github.sha }}
+ TS_RELEASE_TRANSFER_ARTIFACT_ID: ${{ needs.test-release-transfer-upload.outputs.artifact-id }}
+ TS_RELEASE_TRANSFER_ARTIFACT_NAME: ${{ needs.test-release-transfer-upload.outputs.artifact-name }}
+ TS_RELEASE_TRANSFER_ARCHIVE_SHA256: ${{ needs.test-release-transfer-upload.outputs.artifact-digest }}
+ run: |
+ set -euo pipefail
+ TEST_NAME=collection::transfer::ordinary::real_uploaded_archive
+ cargo +"$RUST_VERSION" test -p trusted-server-release --target "$HOST_TARGET" --locked "$TEST_NAME" -- --ignored --exact --nocapture
+
+ test-release-workflow-contract:
+ name: Release validation helper contracts (pure)
+ runs-on: ubuntu-24.04
+ timeout-minutes: 5
+ steps:
+ - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 (Node 24)
+ with:
+ persist-credentials: false
+
+ - name: Read pinned Node version
+ id: node
+ shell: bash
+ run: |
+ echo "version=$(awk '$1 == "nodejs" { print $2 }' .tool-versions)" >> "$GITHUB_OUTPUT"
+
+ - uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0 (Node 24)
+ with:
+ node-version: ${{ steps.node.outputs.version }}
+
+ - name: Check invocation and waiter contracts without network or child processes
+ run: node --test .github/workflows/release-validation.test.mjs
diff --git a/CHANGELOG.md b/CHANGELOG.md
index f691c6a005..70220a2ba8 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -9,7 +9,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Changed
-- **Breaking:** `x-ts-version` now reports the deployed git version — the release tag, else the branch, else the first 6 characters of the commit — compiled in from the build-time `TRUSTED_SERVER__GIT_VERSION` (set by the deploy pipeline) or local git, and is sent by every adapter, including on the Fastly `GET /health` probe. The Fastly service version it previously carried is now `x-ts-fastly-version`; update dashboards, monitors, and scripts that read `x-ts-version` as the Fastly version number. Builds with neither the override nor git omit the header.
+- **Breaking:** `x-ts-version` reports the approved full 40-character source commit for canonical attested releases; the external release tag belongs to the signed release index. Development source builds retain the build-time `TRUSTED_SERVER__GIT_VERSION` override, else local tag, branch or first 6 commit characters, and omit the header when neither override nor Git is available. Every adapter sends the available value, including on the Fastly `GET /health` probe. The Fastly service version is now `x-ts-fastly-version`; update dashboards, monitors and scripts that read `x-ts-version` as the Fastly version number. The source header alone does not authenticate executable bytes.
+- Document manual Fastly production consumption of retained verified wasm with a source-built package inspector, a restricted deployment-only manifest, exact package/member checks and explicit service/base-version selection. This route does not supply integrated preactivation binding checks, staging isolation or durable structured recovery; provider-free checks do not establish live deployment qualification.
- **Breaking:** `ts prebid bundle` is now `ts prebid client`, alongside the new `ts prebid server` namespace. Update scripts and runbooks to use `ts prebid client` with the same arguments. The old `bundle` spelling is no longer accepted and has no compatibility alias.
- The S2S `/_ts/api/v1/batch-sync` endpoint now validates the full batch and calls the CAS-protected update path once per distinct normalized EC ID. The last valid UID wins within a group, and infrastructure failures reject the failing and each unprocessed group, so accepted and `kv_unavailable` input indexes may interleave.
- **Breaking:** Auction providers and bidder routes now use the configuration-first `[auction.providers.]` and `[auction.bidders.]` maps. The removed `[auction].providers = [...]` list and removed server fields under `[integrations.prebid]` and `[integrations.aps]` are rejected even when those integrations are disabled, and `ts config push` rejects the old shape before publication. Move PBS `server_url` to provider `endpoint`, server timeout to provider `timeout_ms`, request controls and bidder-parameter overrides to the `prebid-server` `profile_config`, notification suppression to `notifications`, and each former server bidder to an `[auction.bidders.]` route. Move APS endpoint, timeout, account, inventory, debug, and creative controls to an `aps` provider and its `profile_config`. Browser Prebid settings remain under `[integrations.prebid]`; values such as timeout and debug that previously affected both browser and server behavior must now be configured for each owner. Provider endpoints must be absolute HTTPS URLs. Only bidder codes present in `[auction.bidders]` are folded into Trusted Server requests; unlisted publisher bids remain native browser demand. Provider response names now use the configured provider ID, such as `pbs-main`, instead of the legacy literal `prebid`; audit consumers that match `AuctionResponse.provider`. This schema has no mixed-version-safe deployment order: old binaries reject the maps and new binaries reject the retired fields, so activate the new binary and config blob together. Rollbacks must restore an old-schema blob together with the old binary.
diff --git a/Cargo.lock b/Cargo.lock
index 7ebc0b08ea..361f3b3c64 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -18,6 +18,20 @@ dependencies = [
"inout",
]
+[[package]]
+name = "ahash"
+version = "0.8.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75"
+dependencies = [
+ "cfg-if",
+ "getrandom 0.3.4",
+ "once_cell",
+ "serde",
+ "version_check",
+ "zerocopy",
+]
+
[[package]]
name = "aho-corasick"
version = "1.1.4"
@@ -387,6 +401,21 @@ version = "1.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06"
+[[package]]
+name = "bit-set"
+version = "0.8.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3"
+dependencies = [
+ "bit-vec",
+]
+
+[[package]]
+name = "bit-vec"
+version = "0.8.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7"
+
[[package]]
name = "bitflags"
version = "1.3.2"
@@ -515,6 +544,12 @@ dependencies = [
"serde_with",
]
+[[package]]
+name = "borrow-or-share"
+version = "0.2.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "dc0b364ead1874514c8c2855ab558056ebfeb775653e7ae45ff72f28f8f3166c"
+
[[package]]
name = "brotli"
version = "8.0.4"
@@ -568,6 +603,12 @@ version = "3.20.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649"
+[[package]]
+name = "bytecount"
+version = "0.6.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "175812e0be2bccb6abe50bb8d566126198344f707e304f45c648fd8f2cc0365e"
+
[[package]]
name = "byteorder"
version = "1.5.0"
@@ -1037,6 +1078,30 @@ dependencies = [
"itertools 0.10.5",
]
+[[package]]
+name = "critical-section"
+version = "1.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "790eea4361631c5e7d22598ecd5723ff611904e3344ce8720784c93e3d83d40b"
+
+[[package]]
+name = "crossbeam-channel"
+version = "0.5.17"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "98b0cc327b5bc766e7fda9c9260cc0fa81b43a8e240440422dff70788e3f9ef1"
+dependencies = [
+ "crossbeam-utils",
+]
+
+[[package]]
+name = "crossbeam-epoch"
+version = "0.9.20"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2d6914041f254d6e9176c01941b21115dcfb7089e55135a35411081bd106ef3f"
+dependencies = [
+ "crossbeam-utils",
+]
+
[[package]]
name = "crossbeam-utils"
version = "0.8.23"
@@ -1760,6 +1825,15 @@ dependencies = [
"stable_deref_trait",
]
+[[package]]
+name = "email_address"
+version = "0.2.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e079f19b08ca6239f47f8ba8509c11cf3ea30095831f7fed61441475edd8c449"
+dependencies = [
+ "serde",
+]
+
[[package]]
name = "encoding_rs"
version = "0.8.35"
@@ -1769,6 +1843,18 @@ dependencies = [
"cfg-if",
]
+[[package]]
+name = "enum-as-inner"
+version = "0.6.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a1e6a265c649f3f5979b601d26f1d05ada116434c87741c9493cb56218f76cbc"
+dependencies = [
+ "heck",
+ "proc-macro2",
+ "quote",
+ "syn 2.0.118",
+]
+
[[package]]
name = "env_filter"
version = "2.0.0"
@@ -1872,6 +1958,17 @@ version = "0.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a"
+[[package]]
+name = "fancy-regex"
+version = "0.19.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d301f5bf187b3c295fce6468d3875037a0bccc5f6b151c63cac2f85babf21912"
+dependencies = [
+ "bit-set",
+ "regex-automata",
+ "regex-syntax",
+]
+
[[package]]
name = "faster-hex"
version = "0.10.0"
@@ -2015,6 +2112,17 @@ dependencies = [
"num-traits",
]
+[[package]]
+name = "fluent-uri"
+version = "0.4.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bc74ac4d8359ae70623506d512209619e5cf8f347124910440dbc221714b328e"
+dependencies = [
+ "borrow-or-share",
+ "ref-cast",
+ "serde",
+]
+
[[package]]
name = "fnv"
version = "1.0.7"
@@ -2036,6 +2144,16 @@ dependencies = [
"percent-encoding",
]
+[[package]]
+name = "fraction"
+version = "0.17.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e246562084dde8ebbcc943b261c406ce4f68e5032ec28029a251a47d6a295500"
+dependencies = [
+ "num",
+ "num-bigint",
+]
+
[[package]]
name = "fs_extra"
version = "1.3.0"
@@ -3220,6 +3338,52 @@ version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
+[[package]]
+name = "hickory-proto"
+version = "0.25.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f8a6fe56c0038198998a6f217ca4e7ef3a5e51f46163bd6dd60b5c71ca6c6502"
+dependencies = [
+ "async-trait",
+ "cfg-if",
+ "data-encoding",
+ "enum-as-inner",
+ "futures-channel",
+ "futures-io",
+ "futures-util",
+ "idna",
+ "ipnet",
+ "once_cell",
+ "rand 0.9.4",
+ "ring",
+ "thiserror 2.0.18",
+ "tinyvec",
+ "tokio",
+ "tracing",
+ "url",
+]
+
+[[package]]
+name = "hickory-resolver"
+version = "0.25.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "dc62a9a99b0bfb44d2ab95a7208ac952d31060efc16241c87eaf36406fecf87a"
+dependencies = [
+ "cfg-if",
+ "futures-util",
+ "hickory-proto",
+ "ipconfig",
+ "moka",
+ "once_cell",
+ "parking_lot",
+ "rand 0.9.4",
+ "resolv-conf",
+ "smallvec",
+ "thiserror 2.0.18",
+ "tokio",
+ "tracing",
+]
+
[[package]]
name = "hmac"
version = "0.13.0"
@@ -3428,7 +3592,7 @@ dependencies = [
"prettyplease",
"proc-macro2",
"quote",
- "strum_macros",
+ "strum_macros 0.27.2",
"syn 2.0.118",
"thiserror 2.0.18",
"walkdir",
@@ -3626,6 +3790,19 @@ dependencies = [
"winapi",
]
+[[package]]
+name = "ipconfig"
+version = "0.3.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4d40460c0ce33d6ce4b0630ad68ff63d6661961c48b6dba35e5a4d81cfb48222"
+dependencies = [
+ "socket2",
+ "widestring",
+ "windows-registry",
+ "windows-result",
+ "windows-sys 0.61.2",
+]
+
[[package]]
name = "ipnet"
version = "2.12.0"
@@ -3832,6 +4009,59 @@ dependencies = [
"serde",
]
+[[package]]
+name = "jsonschema"
+version = "0.58.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f622c574a29fb3034a0a90645afee194dfedc75fc124228801b6e77041075bd1"
+dependencies = [
+ "ahash",
+ "bytecount",
+ "data-encoding",
+ "email_address",
+ "fancy-regex",
+ "fraction",
+ "getrandom 0.3.4",
+ "itoa",
+ "jsonschema-regex",
+ "jsonschema-value",
+ "num-cmp",
+ "num-traits",
+ "percent-encoding",
+ "referencing",
+ "regex",
+ "serde",
+ "serde_json",
+ "strum 0.28.0",
+ "unicode-general-category",
+ "uuid-simd",
+]
+
+[[package]]
+name = "jsonschema-regex"
+version = "0.58.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a968fffd387b2ab005bf00c1851f5d26e90df05b6f7ad609ed0d620033cca5e7"
+dependencies = [
+ "regex-syntax",
+]
+
+[[package]]
+name = "jsonschema-value"
+version = "0.58.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "39b7670345e5ce87371339d13705dbb9276b7e3e934bbe1efa2c6734a1f65a96"
+dependencies = [
+ "ahash",
+ "bytecount",
+ "fraction",
+ "getrandom 0.3.4",
+ "num-cmp",
+ "num-traits",
+ "serde_json",
+ "zmij",
+]
+
[[package]]
name = "kstring"
version = "2.0.2"
@@ -4047,6 +4277,12 @@ dependencies = [
"libc",
]
+[[package]]
+name = "micromap"
+version = "0.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c2a86d3146ed3995b5913c414f6664344b9617457320782e64f0bb44afd49d74"
+
[[package]]
name = "mime"
version = "0.3.17"
@@ -4080,6 +4316,23 @@ dependencies = [
"windows-sys 0.61.2",
]
+[[package]]
+name = "moka"
+version = "0.12.15"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "957228ad12042ee839f93c8f257b62b4c0ab5eaae1d4fa60de53b27c9d7c5046"
+dependencies = [
+ "crossbeam-channel",
+ "crossbeam-epoch",
+ "crossbeam-utils",
+ "equivalent",
+ "parking_lot",
+ "portable-atomic",
+ "smallvec",
+ "tagptr",
+ "uuid",
+]
+
[[package]]
name = "multimap"
version = "0.10.1"
@@ -4148,9 +4401,9 @@ dependencies = [
[[package]]
name = "num-bigint"
-version = "0.4.6"
+version = "0.4.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9"
+checksum = "c89e69e7e0f03bea5ef08013795c25018e101932225a656383bd384495ecc367"
dependencies = [
"num-integer",
"num-traits",
@@ -4172,6 +4425,12 @@ dependencies = [
"zeroize",
]
+[[package]]
+name = "num-cmp"
+version = "0.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "63335b2e2c34fae2fb0aa2cecfd9f0832a1e24b3b32ecec612c3426d46dc8aaa"
+
[[package]]
name = "num-complex"
version = "0.4.6"
@@ -4263,6 +4522,15 @@ dependencies = [
"libc",
]
+[[package]]
+name = "object"
+version = "0.37.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ff76201f031d8863c38aa7f905eca4f53abbfa15f609db4277d44cd8938f33fe"
+dependencies = [
+ "memchr",
+]
+
[[package]]
name = "oid-registry"
version = "0.7.1"
@@ -4277,6 +4545,10 @@ name = "once_cell"
version = "1.21.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
+dependencies = [
+ "critical-section",
+ "portable-atomic",
+]
[[package]]
name = "once_cell_polyfill"
@@ -4318,6 +4590,12 @@ dependencies = [
"hashbrown 0.14.5",
]
+[[package]]
+name = "outref"
+version = "0.5.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1a80800c0488c3a21695ea981a54918fbb37abf04f4d0720c453632255e2ff0e"
+
[[package]]
name = "p256"
version = "0.13.2"
@@ -5079,6 +5357,23 @@ dependencies = [
"syn 2.0.118",
]
+[[package]]
+name = "referencing"
+version = "0.58.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "558f3855529c0a7e78a48e409fa653456e55f1b167eab4109c16a255b7727e3f"
+dependencies = [
+ "ahash",
+ "fluent-uri",
+ "getrandom 0.3.4",
+ "hashbrown 0.17.1",
+ "itoa",
+ "micromap",
+ "parking_lot",
+ "percent-encoding",
+ "serde_json",
+]
+
[[package]]
name = "regex"
version = "1.12.4"
@@ -5093,9 +5388,9 @@ dependencies = [
[[package]]
name = "regex-automata"
-version = "0.4.14"
+version = "0.4.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f"
+checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2"
dependencies = [
"aho-corasick",
"memchr",
@@ -5121,6 +5416,7 @@ dependencies = [
"futures-channel",
"futures-core",
"futures-util",
+ "hickory-resolver",
"http",
"http-body",
"http-body-util",
@@ -5129,6 +5425,7 @@ dependencies = [
"hyper-util",
"js-sys",
"log",
+ "once_cell",
"percent-encoding",
"pin-project-lite",
"quinn",
@@ -5189,6 +5486,12 @@ dependencies = [
"web-sys",
]
+[[package]]
+name = "resolv-conf"
+version = "0.7.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1e061d1b48cb8d38042de4ae0a7a6401009d6143dc80d2e2d6f31f0bdd6470c7"
+
[[package]]
name = "ring"
version = "0.17.14"
@@ -6004,7 +6307,16 @@ version = "0.27.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "af23d6f6c1a224baef9d3f61e287d2761385a5b88fdab4eb4c6f11aeb54c4bcf"
dependencies = [
- "strum_macros",
+ "strum_macros 0.27.2",
+]
+
+[[package]]
+name = "strum"
+version = "0.28.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9628de9b8791db39ceda2b119bbe13134770b56c138ec1d3af810d045c04f9bd"
+dependencies = [
+ "strum_macros 0.28.0",
]
[[package]]
@@ -6019,6 +6331,18 @@ dependencies = [
"syn 2.0.118",
]
+[[package]]
+name = "strum_macros"
+version = "0.28.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ab85eea0270ee17587ed4156089e10b9e6880ee688791d45a905f5b1ca36f664"
+dependencies = [
+ "heck",
+ "proc-macro2",
+ "quote",
+ "syn 2.0.118",
+]
+
[[package]]
name = "subtle"
version = "2.6.1"
@@ -6078,6 +6402,22 @@ dependencies = [
"syn 2.0.118",
]
+[[package]]
+name = "tagptr"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7b2093cf4c8eb1e67749a6762251bc9cd836b6fc171623bd0a9d324d37af2417"
+
+[[package]]
+name = "tar"
+version = "0.4.46"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3f6221d9a6003c78398e3b239969f352578258df48c8eb051caadae0015bc840"
+dependencies = [
+ "filetime",
+ "libc",
+]
+
[[package]]
name = "temp-env"
version = "0.3.6"
@@ -6646,10 +6986,13 @@ dependencies = [
"futures",
"gix",
"glob",
+ "hex",
"http",
"http-body-util",
"hyper",
"hyper-util",
+ "jsonschema",
+ "libc",
"log",
"predicates",
"rand 0.8.6",
@@ -6663,6 +7006,7 @@ dependencies = [
"serde",
"serde_json",
"serde_yaml_ng",
+ "sha2 0.11.1",
"similar 2.7.0",
"temp-env",
"tempfile",
@@ -6768,7 +7112,11 @@ version = "0.1.0"
dependencies = [
"build-print",
"hex",
+ "libc",
+ "serde",
+ "serde_json",
"sha2 0.11.1",
+ "tempfile",
"which",
]
@@ -6788,6 +7136,35 @@ dependencies = [
"prost-build",
]
+[[package]]
+name = "trusted-server-release"
+version = "0.1.0"
+dependencies = [
+ "base64",
+ "clap",
+ "derive_more",
+ "error-stack",
+ "flate2",
+ "hex",
+ "hmac",
+ "jsonschema",
+ "libc",
+ "object",
+ "rcgen",
+ "reqwest 0.12.28",
+ "rustls",
+ "serde",
+ "serde_json",
+ "serde_yaml_ng",
+ "sha1-checked",
+ "sha2 0.11.1",
+ "tar",
+ "tempfile",
+ "tokio",
+ "toml",
+ "wasmparser",
+]
+
[[package]]
name = "try-lock"
version = "0.2.5"
@@ -6845,6 +7222,12 @@ version = "2.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7eec5d1121208364f6793f7d2e222bf75a915c19557537745b195b253dd64217"
+[[package]]
+name = "unicode-general-category"
+version = "1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0b993bddc193ae5bd0d623b49ec06ac3e9312875fdae725a975c51db1cc1677f"
+
[[package]]
name = "unicode-ident"
version = "1.0.24"
@@ -6981,6 +7364,16 @@ dependencies = [
"wasm-bindgen",
]
+[[package]]
+name = "uuid-simd"
+version = "0.8.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "23b082222b4f6619906941c17eb2297fff4c2fb96cb60164170522942a200bd8"
+dependencies = [
+ "outref",
+ "vsimd",
+]
+
[[package]]
name = "validator"
version = "0.21.0"
@@ -7022,6 +7415,12 @@ version = "0.9.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
+[[package]]
+name = "vsimd"
+version = "0.8.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5c3082ca00d5a5ef149bb8b555a72ae84c9c59f7250f013ac822ac2e49b19c64"
+
[[package]]
name = "wait-timeout"
version = "0.2.1"
@@ -7239,6 +7638,12 @@ dependencies = [
"libc",
]
+[[package]]
+name = "widestring"
+version = "1.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "72069c3113ab32ab29e5584db3c6ec55d416895e60715417b5b883a357c3e471"
+
[[package]]
name = "winapi"
version = "0.3.9"
@@ -7635,7 +8040,7 @@ dependencies = [
"serde-wasm-bindgen",
"serde_json",
"serde_urlencoded",
- "strum",
+ "strum 0.27.2",
"tokio",
"url",
"wasm-bindgen",
@@ -7655,7 +8060,7 @@ dependencies = [
"async-trait",
"proc-macro2",
"quote",
- "strum",
+ "strum 0.27.2",
"syn 2.0.118",
"wasm-bindgen",
"wasm-bindgen-macro-support",
@@ -7842,6 +8247,6 @@ checksum = "34b31d188d9d685a4f9c7b46d6e36631b07058d2cfe190267adce54dc230bf12"
[[package]]
name = "zmij"
-version = "1.0.21"
+version = "1.0.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa"
+checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
diff --git a/Cargo.toml b/Cargo.toml
index 72fecdf6b5..b8525f032d 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -13,6 +13,7 @@ members = [
# Manual protobuf codegen tool — a member so it shares workspace settings,
# but no default member or alias builds it. Run it explicitly with `-p`.
"crates/trusted-server-openrtb-codegen",
+ "crates/trusted-server-release",
]
# Viceroy (cargo test-fastly runner) calls `cargo run --bin trusted-server-adapter-fastly`
diff --git a/crates/trusted-server-adapter-cloudflare/build.sh b/crates/trusted-server-adapter-cloudflare/build.sh
index 844503f30a..b3a6dd3a30 100644
--- a/crates/trusted-server-adapter-cloudflare/build.sh
+++ b/crates/trusted-server-adapter-cloudflare/build.sh
@@ -25,6 +25,9 @@ cd "$SCRIPT_DIR"
# — a flag the CLI matching the pinned wasm-bindgen (0.2.123, via worker 0.8.4)
# does not understand. Tracking the locked worker version keeps the toolchain in
# lockstep, and the pin advances automatically when the lockfile bumps.
+# `--locked` installs with worker-build's own published Cargo.lock: a fresh
+# resolution fails once its dependencies yank every compatible release (for
+# example every `zip` 2.x needed by its `binary-install` dependency).
WORKER_VERSION="$(
grep -A1 '^name = "worker"$' "$SCRIPT_DIR/../../Cargo.lock" |
sed -nE 's/^version = "(.*)"/\1/p' | head -1
@@ -33,7 +36,4 @@ if [ -z "$WORKER_VERSION" ]; then
echo "error: could not determine the worker crate version from Cargo.lock" >&2
exit 1
fi
-# --locked builds from worker-build's published Cargo.lock. Without it, cargo
-# re-resolves dependencies, and a yanked transitive release (zip 2.x) fails the
-# install.
-cargo install -q --force --locked --version "=$WORKER_VERSION" worker-build && worker-build --release
+cargo install -q --locked --force --version "=$WORKER_VERSION" worker-build && worker-build --release
diff --git a/crates/trusted-server-adapter-fastly/build.rs b/crates/trusted-server-adapter-fastly/build.rs
new file mode 100644
index 0000000000..0889fe217b
--- /dev/null
+++ b/crates/trusted-server-adapter-fastly/build.rs
@@ -0,0 +1,26 @@
+//! Compile immutable facts owned by the Fastly product.
+
+#![allow(
+ clippy::print_stdout,
+ reason = "Cargo build script directives use stdout"
+)]
+
+#[allow(
+ dead_code,
+ reason = "This owner uses only release validation from the shared resolver"
+)]
+#[path = "../trusted-server-core/build_support/git_version.rs"]
+mod git_version;
+
+fn main() {
+ println!("cargo:rerun-if-changed=build.rs");
+ println!("cargo:rerun-if-changed=../trusted-server-core/build_support/git_version.rs");
+ println!("cargo:rerun-if-env-changed=TRUSTED_SERVER__RELEASE_BUILD");
+ println!("cargo:rerun-if-env-changed=TRUSTED_SERVER__GIT_VERSION");
+ let control = std::env::var_os("TRUSTED_SERVER__RELEASE_BUILD");
+ let source = std::env::var_os("TRUSTED_SERVER__GIT_VERSION");
+ git_version::release_source(control.as_deref(), source.as_deref())
+ .expect("should provide valid release build inputs");
+ let target = std::env::var("TARGET").expect("should set the owning Cargo target");
+ println!("cargo:rustc-env=TRUSTED_SERVER__BUILD_TARGET={target}");
+}
diff --git a/crates/trusted-server-adapter-fastly/src/main.rs b/crates/trusted-server-adapter-fastly/src/main.rs
index b8f814a01c..c3e8090f43 100644
--- a/crates/trusted-server-adapter-fastly/src/main.rs
+++ b/crates/trusted-server-adapter-fastly/src/main.rs
@@ -11,6 +11,7 @@ use error_stack::Report;
use fastly::http::Method as FastlyMethod;
use fastly::{Request as FastlyRequest, Response as FastlyResponse};
+use trusted_server_core::build_info::{BuildInfo, BuildVariant};
use trusted_server_core::cache_policy::{EdgeCacheHeader, cache_control_headers_have_directive};
use trusted_server_core::constants::HEADER_X_TS_VERSION;
use trusted_server_core::ec::device::DeviceSignals;
@@ -56,6 +57,17 @@ use crate::sandbox::{RetainedApp, Sandbox, SandboxCounters, ServeMode, StartupDi
#[cfg(feature = "reusable-sandbox")]
use crate::sandbox::RetirementCounters;
+/// Immutable facts compiled into the owning Fastly product.
+pub const BUILD_INFO: BuildInfo = BuildInfo::new(
+ env!("CARGO_PKG_VERSION"),
+ env!("TRUSTED_SERVER__BUILD_TARGET"),
+ if cfg!(feature = "reusable-sandbox") {
+ BuildVariant::ReusableSandbox
+ } else {
+ BuildVariant::Default
+ },
+);
+
/// Opens the Fastly Config Store used by the `EdgeZero` dispatcher.
///
/// # Errors
@@ -97,10 +109,7 @@ fn main() {
// Held outside the sandbox: `serve_custom` owns the `Sandbox` and exposes
// no slot for application state that is not the retained payload.
- let mut startup = StartupDiagnostics::default();
- for message in diagnostics {
- startup.push(message);
- }
+ let mut startup = startup_diagnostics(diagnostics);
match mode {
ServeMode::Single => {
@@ -116,6 +125,21 @@ fn main() {
}
}
+fn startup_diagnostics(diagnostics: Vec) -> StartupDiagnostics {
+ let mut startup = StartupDiagnostics::default();
+ startup.push(format!(
+ "Build facts: package={} source={} target={} variant={}",
+ BUILD_INFO.package_version(),
+ BUILD_INFO.source().unwrap_or("unknown"),
+ BUILD_INFO.target(),
+ BUILD_INFO.variant()
+ ));
+ for message in diagnostics {
+ startup.push(message);
+ }
+ startup
+}
+
/// Serves requests from one sandbox under explicit bounds.
///
/// Only compiled with the `reusable-sandbox` feature; [`serve_mode`] can never
@@ -912,6 +936,51 @@ mod tests {
);
}
+ #[test]
+ fn fastly_build_info_uses_actual_owning_package_target_source_and_feature() {
+ assert_eq!(
+ BUILD_INFO.package_version(),
+ env!("CARGO_PKG_VERSION"),
+ "should own Fastly package version"
+ );
+ assert_eq!(
+ BUILD_INFO.target(),
+ "wasm32-wasip1",
+ "should own Fastly Cargo target"
+ );
+ assert_eq!(
+ BUILD_INFO.source(),
+ trusted_server_core::constants::TS_GIT_VERSION,
+ "should read source from core"
+ );
+ let expected = if cfg!(feature = "reusable-sandbox") {
+ BuildVariant::ReusableSandbox
+ } else {
+ BuildVariant::Default
+ };
+ assert_eq!(
+ BUILD_INFO.variant(),
+ expected,
+ "should derive actual Fastly feature variant"
+ );
+ }
+
+ #[test]
+ fn startup_queues_one_build_fact_message_for_existing_log_flush() {
+ let startup = startup_diagnostics(Vec::new());
+ assert_eq!(
+ startup.pending(),
+ 1,
+ "should queue exactly one build fact message"
+ );
+ let startup = startup_diagnostics(vec!["example diagnostic".to_owned()]);
+ assert_eq!(
+ startup.pending(),
+ 2,
+ "should preserve existing startup diagnostics"
+ );
+ }
+
#[test]
fn health_response_reports_git_version_only() {
let req = FastlyRequest::get("https://example.com/health");
diff --git a/crates/trusted-server-adapter-spin/src/app.rs b/crates/trusted-server-adapter-spin/src/app.rs
index 804a23b620..d82c667913 100644
--- a/crates/trusted-server-adapter-spin/src/app.rs
+++ b/crates/trusted-server-adapter-spin/src/app.rs
@@ -567,8 +567,8 @@ impl TrustedServerApp {
/// Build the full application router from explicit settings.
///
/// Testing seam: cross-adapter parity tests use this to drive the router
- /// with known-good settings instead of the baked `get_settings()` result,
- /// whose embedded placeholder secrets fail validation by design.
+ /// with known-good settings without external runtime configuration or
+ /// secret-store bindings.
///
/// # Errors
///
diff --git a/crates/trusted-server-adapter-spin/src/middleware.rs b/crates/trusted-server-adapter-spin/src/middleware.rs
index fc1b939bd5..94e875cfd3 100644
--- a/crates/trusted-server-adapter-spin/src/middleware.rs
+++ b/crates/trusted-server-adapter-spin/src/middleware.rs
@@ -221,9 +221,8 @@ mod tests {
}
fn settings_with_response_headers(headers: Vec<(&str, &str)>) -> Settings {
- // Build from explicit test settings: the settings baked into the
- // binary contain placeholder secrets that `get_settings()` rejects
- // by design.
+ // Build explicit, deterministic test settings without external runtime
+ // configuration or secret-store bindings.
let mut s = Settings::from_toml(
r#"
[[handlers]]
diff --git a/crates/trusted-server-adapter-spin/tests/routes.rs b/crates/trusted-server-adapter-spin/tests/routes.rs
index 613832b059..4e999e0e0a 100644
--- a/crates/trusted-server-adapter-spin/tests/routes.rs
+++ b/crates/trusted-server-adapter-spin/tests/routes.rs
@@ -14,9 +14,9 @@ use trusted_server_core::settings::Settings;
/// Build the full application router from explicit test settings.
///
-/// The settings baked into the binary contain placeholder secrets that
-/// `get_settings()` rejects by design, which would turn every route into a
-/// startup error page (and its route table into the fallback-only set).
+/// Explicit settings exercise the full route table without external runtime
+/// configuration or secret-store bindings. Missing runtime settings would
+/// otherwise select the startup error router.
/// The handler regex is the production-shaped `^/_ts/admin`, matching
/// `Settings::ADMIN_ENDPOINTS` and the default config, so the canonical
/// `/_ts/admin/keys/*` routes are auth-gated exactly as in production.
diff --git a/crates/trusted-server-cli/Cargo.toml b/crates/trusted-server-cli/Cargo.toml
index ce97919c81..ec620760ed 100644
--- a/crates/trusted-server-cli/Cargo.toml
+++ b/crates/trusted-server-cli/Cargo.toml
@@ -85,6 +85,17 @@ hyper = { workspace = true, features = ["http1", "client"] }
hyper-util = { workspace = true, features = ["tokio"] }
tokio = { workspace = true, features = ["net", "rt-multi-thread"] }
+# Consumer release verification (`src/release`), compiled only where the
+# module is: Unix hosts. `sha2`/`hex` hash exact distributed bytes, `libc`
+# provides process-group termination and `openat` snapshot reads, and
+# `jsonschema` evaluates the governing release-index schema offline. The
+# `jsonschema` exact pin and empty default features match `trusted-server-release`.
+[target.'cfg(all(unix, not(target_arch = "wasm32")))'.dependencies]
+hex = { workspace = true }
+jsonschema = { version = "=0.58.6", default-features = false }
+libc = { workspace = true }
+sha2 = { workspace = true }
+
# The shared proxy engine supports macOS and Linux only.
[target.'cfg(any(target_os = "macos", target_os = "linux"))'.dependencies]
base64 = { workspace = true }
diff --git a/crates/trusted-server-cli/README.md b/crates/trusted-server-cli/README.md
index e73c646283..dbff696426 100644
--- a/crates/trusted-server-cli/README.md
+++ b/crates/trusted-server-cli/README.md
@@ -22,6 +22,22 @@ two-platform command inventory and the
[EdgeZero guide](../../docs/guide/edgezero.md) for configuration lifecycle and
store semantics.
+`ts --version` labels four compiled facts:
+`ts package= source= target= variant=default`.
+The external release tag belongs to the signed release index and is distinct
+from the owning Cargo package version.
+Release builds set `TRUSTED_SERVER__RELEASE_BUILD=1` and require
+`TRUSTED_SERVER__GIT_VERSION` to contain exactly 40 lowercase hexadecimal commit
+characters. Any other present release control fails the build. Without that
+control, development builds retain tag, branch and abbreviated commit discovery.
+These compiled facts identify inputs; they do not establish release provenance.
+
+Help, version output and `ts config init` work outside a source checkout. Config
+initialization writes the embedded fictional example scaffold for the operator
+to configure. `ts prebid client` requires the repository's JavaScript sources and
+local build prerequisites. Release builds discover those resources from the
+current directory's ancestors and omit the developer checkout fallback.
+
## Experimental PBS commands
`ts prebid server` manages local configuration inputs and a small set of AWS operations for self-hosted Prebid Server Go. It sits beside `ts prebid client`, which builds browser JavaScript, and remains separate from the existing Trusted Server `ts config` and `ts deploy` commands.
diff --git a/crates/trusted-server-cli/build.rs b/crates/trusted-server-cli/build.rs
new file mode 100644
index 0000000000..1c40ab678b
--- /dev/null
+++ b/crates/trusted-server-cli/build.rs
@@ -0,0 +1,31 @@
+//! Compile immutable facts owned by the CLI product.
+
+#![allow(
+ clippy::print_stdout,
+ reason = "Cargo build script directives use stdout"
+)]
+
+#[allow(
+ dead_code,
+ reason = "This owner uses only release validation from the shared resolver"
+)]
+#[path = "../trusted-server-core/build_support/git_version.rs"]
+mod git_version;
+
+fn main() {
+ println!("cargo:rerun-if-changed=build.rs");
+ println!("cargo:rerun-if-changed=../trusted-server-core/build_support/git_version.rs");
+ println!("cargo:rerun-if-env-changed=TRUSTED_SERVER__RELEASE_BUILD");
+ println!("cargo:rerun-if-env-changed=TRUSTED_SERVER__GIT_VERSION");
+ println!("cargo:rustc-check-cfg=cfg(trusted_server_release_build)");
+ let control = std::env::var_os("TRUSTED_SERVER__RELEASE_BUILD");
+ let source = std::env::var_os("TRUSTED_SERVER__GIT_VERSION");
+ if git_version::release_source(control.as_deref(), source.as_deref())
+ .expect("should provide valid release build inputs")
+ .is_some()
+ {
+ println!("cargo:rustc-cfg=trusted_server_release_build");
+ }
+ let target = std::env::var("TARGET").expect("should set the owning Cargo target");
+ println!("cargo:rustc-env=TRUSTED_SERVER__BUILD_TARGET={target}");
+}
diff --git a/crates/trusted-server-cli/release-policy.json b/crates/trusted-server-cli/release-policy.json
new file mode 100644
index 0000000000..4aa1115dfa
--- /dev/null
+++ b/crates/trusted-server-cli/release-policy.json
@@ -0,0 +1,140 @@
+{
+ "artifacts": [
+ {
+ "actions": ["readonly-verify"],
+ "assurance_profile": "provenance-sbom-repeatable",
+ "bin": "trusted-server-adapter-fastly",
+ "capabilities": ["fastly-production"],
+ "compatibility": {
+ "architecture": "wasm32",
+ "minimum_version": "wasip1",
+ "runtime": "fastly-compute"
+ },
+ "default_features": true,
+ "features": [],
+ "kind": "wasm-core",
+ "name": "trusted-server-fastly.wasm",
+ "package": "trusted-server-adapter-fastly",
+ "role": "server",
+ "target": "wasm32-wasip1",
+ "variant": "default"
+ },
+ {
+ "actions": ["readonly-verify"],
+ "assurance_profile": "provenance-sbom",
+ "bin": "ts",
+ "capabilities": ["cli-execute"],
+ "compatibility": {
+ "architecture": "aarch64",
+ "minimum_version": "13.0",
+ "runtime": "macos"
+ },
+ "default_features": true,
+ "features": [],
+ "kind": "native-executable",
+ "name": "ts-aarch64-apple-darwin",
+ "package": "trusted-server-cli",
+ "role": "operator-cli",
+ "target": "aarch64-apple-darwin",
+ "variant": "default"
+ },
+ {
+ "actions": ["readonly-verify"],
+ "assurance_profile": "provenance-sbom",
+ "bin": "ts",
+ "capabilities": ["cli-execute"],
+ "compatibility": {
+ "architecture": "x86_64",
+ "minimum_version": "13.0",
+ "runtime": "macos"
+ },
+ "default_features": true,
+ "features": [],
+ "kind": "native-executable",
+ "name": "ts-x86_64-apple-darwin",
+ "package": "trusted-server-cli",
+ "role": "operator-cli",
+ "target": "x86_64-apple-darwin",
+ "variant": "default"
+ },
+ {
+ "actions": ["readonly-verify"],
+ "assurance_profile": "provenance-sbom",
+ "bin": "ts",
+ "capabilities": ["cli-execute"],
+ "compatibility": {
+ "architecture": "x86_64",
+ "minimum_version": "glibc-2.34",
+ "runtime": "linux-gnu"
+ },
+ "default_features": true,
+ "features": [],
+ "kind": "native-executable",
+ "name": "ts-x86_64-unknown-linux-gnu",
+ "package": "trusted-server-cli",
+ "role": "operator-cli",
+ "target": "x86_64-unknown-linux-gnu",
+ "variant": "default"
+ }
+ ],
+ "bounds": {
+ "attempt_seconds": 900,
+ "bundle_bytes": 33554432,
+ "child_seconds": 120,
+ "cohort_executables": 4,
+ "companion_bytes": 33554432,
+ "executable_bytes": 536870912,
+ "index_bytes": 1048576,
+ "inventory_bytes": 1048576,
+ "json_depth": 64,
+ "matching_attestations_per_bundle": 1,
+ "metadata_bytes": 1048576,
+ "policy_bytes": 1048576,
+ "spdx_bytes": 16777216,
+ "stderr_bytes": 1048576,
+ "stdout_bytes": 134217728,
+ "trusted_roots_bytes": 1048576
+ },
+ "cohort": "phase1-fastly-cli",
+ "github_host": "github.com",
+ "historical_producer_policies": [],
+ "identity": {
+ "builder_id": "https://github.com/IABTechLab/trusted-server/.github/workflows/release-build.yml@refs/tags/",
+ "caller_commit": "same-approved-source-commit",
+ "caller_path": ".github/workflows/release.yml",
+ "caller_repository": "https://github.com/IABTechLab/trusted-server",
+ "certificate_fields": [
+ "buildConfigDigest",
+ "buildConfigURI",
+ "buildTrigger",
+ "runInvocationURI",
+ "sourceRepositoryIdentifier",
+ "sourceRepositoryOwnerIdentifier"
+ ],
+ "certificate_san": "https://github.com/IABTechLab/trusted-server/.github/workflows/release-build.yml@refs/tags/",
+ "event": "push",
+ "external_parameters": "exact-caller-workflow-only-no-inputs",
+ "invocation": "same-repository-run-and-attempt-across-certificate-and-provenance",
+ "issuer": "https://token.actions.githubusercontent.com",
+ "runner": "github-hosted",
+ "signer_commit": "same-approved-source-commit",
+ "source_commit": "independently-approved-40-lowercase-hex",
+ "source_dependency": "git+https://github.com/IABTechLab/trusted-server@refs/tags/",
+ "source_ref": "refs/tags/"
+ },
+ "policy_version": 1,
+ "predicates": {
+ "build_type": "https://actions.github.io/buildtypes/workflow/v1",
+ "provenance": "https://slsa.dev/provenance/v1",
+ "sbom": "https://spdx.dev/Document/v2.3"
+ },
+ "public_actions": ["readonly-verify"],
+ "repository": "IABTechLab/trusted-server",
+ "repository_id": "950860906",
+ "repository_owner_id": "82903538",
+ "tool_versions": {
+ "actions_attest": "4.2.2",
+ "actions_attest_revision": "1e69f48acb82d1966a394da916b4c1698aa569d6",
+ "gh": "2.97.0"
+ }
+}
diff --git a/crates/trusted-server-cli/src/commands/dev/lint/domains.rs b/crates/trusted-server-cli/src/commands/dev/lint/domains.rs
index 8978af61c0..7ca6449c84 100644
--- a/crates/trusted-server-cli/src/commands/dev/lint/domains.rs
+++ b/crates/trusted-server-cli/src/commands/dev/lint/domains.rs
@@ -112,8 +112,11 @@ pub const REFERENCE_HOSTS: &[&str] = &[
"iabtechlab.github.io",
"iabeurope.github.io",
// Specs (supply chain)
+ "actions.github.io",
"in-toto.io",
"rslstandard.org",
+ "slsa.dev",
+ "spdx.dev",
// Specs (other)
"webassembly.org",
// Fastly docs
@@ -497,6 +500,36 @@ fn canonical_host(authority: &str) -> String {
}
}
+// These are exact standard identifiers, rather than general host permissions.
+const STANDARD_IDENTIFIERS: &[&str] = &[
+ "http://json-schema.org/draft-07/schema#",
+ "https://json-schema.org/draft/2020-12/schema",
+ "http://spdx.org/rdf/terms/2.3",
+ "http://spdx.org/rdf/terms#none",
+ "http://spdx.org/rdf/terms#noassertion",
+];
+
+fn is_standard_identifier(line: &str, start: usize) -> bool {
+ let Some(prefix) = line.get(..start) else {
+ return false;
+ };
+ let Some(suffix) = line.get(start..) else {
+ return false;
+ };
+ if prefix.chars().next_back().is_some_and(|character| {
+ !character.is_whitespace() && !matches!(character, '"' | '\'' | '`' | '(' | '[' | '<' | '{')
+ }) {
+ return false;
+ }
+ STANDARD_IDENTIFIERS.iter().any(|identifier| {
+ suffix.strip_prefix(identifier).is_some_and(|rest| {
+ rest.chars().next().is_none_or(|character| {
+ character.is_whitespace() || matches!(character, '"' | '\'' | '`')
+ })
+ })
+ })
+}
+
/// Extract and normalise every host from absolute URLs on `line`.
///
/// The scheme itself marks these as URLs, so an authority that merely
@@ -507,6 +540,11 @@ fn extract_absolute_hosts(line: &str) -> Vec {
let line = unescape_solidus(line);
absolute_url_regex()
.captures_iter(&line)
+ .filter(|capture| {
+ !capture
+ .get(0)
+ .is_some_and(|matched| is_standard_identifier(&line, matched.start()))
+ })
.filter_map(|c| c.get(1).map(|m| canonical_host(m.as_str())))
.filter(|host| host.chars().any(char::is_alphanumeric))
.collect()
@@ -1369,6 +1407,245 @@ mod scan_line_tests {
}
}
+ fn standard_identifiers() -> [&'static str; 5] {
+ [
+ "http://json-schema.org/draft-07/schema#",
+ "https://json-schema.org/draft/2020-12/schema",
+ "http://spdx.org/rdf/terms/2.3",
+ "http://spdx.org/rdf/terms#none",
+ "http://spdx.org/rdf/terms#noassertion",
+ ]
+ }
+
+ fn standard_host(identifier: &str) -> String {
+ Url::parse(identifier)
+ .expect("should parse exact standard identifier")
+ .host_str()
+ .expect("should include standard host")
+ .to_owned()
+ }
+
+ #[test]
+ fn normative_identifiers_accept_exact_json_and_quoted_prose() {
+ for identifier in standard_identifiers() {
+ for line in [
+ format!(r#"{{"$schema":"{identifier}"}}"#),
+ format!("The identifier is `{identifier}` in this fixture."),
+ identifier.to_owned(),
+ format!("'{}'", identifier.replace('/', "\\/")),
+ ] {
+ assert!(
+ hosts(&line).is_empty(),
+ "should accept exact standard identifier: {line}"
+ );
+ }
+ }
+ }
+
+ #[test]
+ fn normative_identifiers_reject_prefixed_text_and_every_uri_continuation() {
+ for identifier in standard_identifiers() {
+ let expected = standard_host(identifier);
+ for suffix in [
+ "/",
+ "/extra",
+ "?query",
+ "#fragment",
+ "%20",
+ "@reserved.invalid",
+ ":80",
+ ";extra",
+ ")",
+ ",",
+ "*",
+ "extra",
+ ] {
+ let line = format!("\"{identifier}{suffix}\"");
+ assert_eq!(
+ hosts(&line),
+ vec![expected.clone()],
+ "should reject continued standard URI: {line}"
+ );
+ }
+ assert_eq!(
+ hosts(&format!("\"prefixed{identifier}\"")),
+ vec![expected],
+ "should reject identifier embedded in prefixed text"
+ );
+ }
+ }
+
+ #[test]
+ fn normative_identifiers_require_source_opening_prefix_boundaries() {
+ for identifier in standard_identifiers() {
+ let host = standard_host(identifier);
+ for prefix in [
+ ":",
+ "=",
+ "/",
+ ";",
+ ")",
+ "]",
+ "}",
+ "@",
+ "%",
+ "?",
+ "#",
+ "urn:reference:",
+ "query=",
+ ] {
+ assert_eq!(
+ hosts(&format!("{prefix}{identifier}\"")),
+ vec![host.clone()],
+ "should reject nested or continued URI prefixes"
+ );
+ }
+ for prefix in [" ", "\t", "\"", "'", "`", "(", "[", "<", "{"] {
+ assert!(
+ hosts(&format!("{prefix}{identifier}\"")).is_empty(),
+ "should accept source-opening prefix boundary"
+ );
+ }
+ }
+ }
+
+ #[test]
+ fn normative_identifiers_reject_scheme_authority_and_host_variants() {
+ for identifier in standard_identifiers() {
+ let parsed = Url::parse(identifier).expect("should parse standard URI");
+ let scheme = parsed.scheme();
+ let host = standard_host(identifier);
+ let rest = identifier
+ .strip_prefix(&format!("{scheme}://{host}"))
+ .expect("should provide canonical authority");
+ for authority in [
+ format!("user@{host}"),
+ format!("{host}:80"),
+ format!("subdomain.{host}"),
+ format!("lookalike-{host}"),
+ format!("{host}.unreviewed"),
+ host.replacen('o', "%6f", 1),
+ host.to_uppercase(),
+ ] {
+ let line = format!("\"{scheme}://{authority}{rest}\"");
+ assert!(
+ !hosts(&line).is_empty(),
+ "should reject noncanonical standard authority: {line}"
+ );
+ }
+ for line in [
+ format!("\"//{host}{rest}\""),
+ format!("\"{}://{host}{rest}\"", scheme.to_uppercase()),
+ format!(
+ "\"{}://{host}{rest}\"",
+ if scheme == "http" { "https" } else { "http" }
+ ),
+ ] {
+ assert_eq!(
+ hosts(&line),
+ vec![host.clone()],
+ "should reject noncanonical scheme or protocol-relative URI: {line}"
+ );
+ }
+ }
+ }
+
+ #[test]
+ fn normative_identifiers_do_not_hide_same_host_urls_in_either_order() {
+ for identifier in standard_identifiers() {
+ let host = standard_host(identifier);
+ let noncanonical = format!("{identifier}/unregistered");
+ for line in [
+ format!("\"{identifier}\" \"{noncanonical}\""),
+ format!("\"{noncanonical}\" \"{identifier}\""),
+ ] {
+ assert_eq!(
+ hosts(&line),
+ vec![host.clone()],
+ "should retain the noncanonical occurrence before host deduplication"
+ );
+ }
+ }
+ }
+
+ #[test]
+ fn normative_identifiers_preserve_other_host_violations() {
+ for identifier in standard_identifiers() {
+ let other = if standard_host(identifier) == "spdx.org" {
+ "https://json-schema.org/unregistered"
+ } else {
+ "http://spdx.org/unregistered"
+ };
+ assert_eq!(
+ hosts(&format!("\"{identifier}\" \"{other}\"")),
+ vec![standard_host(other)],
+ "should retain other disallowed URL beside exact identifier"
+ );
+ }
+ }
+
+ #[test]
+ fn normative_identifiers_leave_host_suppressions_unused() {
+ for identifier in standard_identifiers() {
+ let host = standard_host(identifier);
+ let outcome = scan_line(&format!("\"{identifier}\" // allow-domain: {host}"));
+ assert!(
+ outcome.violations.is_empty(),
+ "should accept exact identifier without suppression"
+ );
+ assert_eq!(
+ outcome.unused_suppressions,
+ vec![host],
+ "should report unused host suppression"
+ );
+ }
+ }
+
+ #[test]
+ fn release_policy_standard_identifiers_pass_without_suppression() {
+ let policy = include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/release-policy.json"));
+ for line in policy.lines() {
+ assert!(
+ hosts(line).is_empty(),
+ "should accept reviewed production policy identifiers: {line}"
+ );
+ }
+ }
+
+ #[test]
+ fn release_policy_standard_hosts_reject_subdomains_and_lookalikes() {
+ let policy: serde_json::Value = serde_json::from_str(include_str!(concat!(
+ env!("CARGO_MANIFEST_DIR"),
+ "/release-policy.json"
+ )))
+ .expect("should parse the production release policy");
+ let predicates = policy["predicates"]
+ .as_object()
+ .expect("should provide fixed predicate identities");
+ for predicate in predicates.values() {
+ let identifier = predicate
+ .as_str()
+ .expect("should provide a string predicate identifier");
+ let authority = identifier
+ .strip_prefix("https://")
+ .expect("should use an HTTPS predicate identifier")
+ .split('/')
+ .next()
+ .expect("should contain the standard host");
+ for host in [
+ format!("subdomain.{authority}"),
+ format!("{authority}.unreviewed"),
+ format!("lookalike-{authority}"),
+ ] {
+ assert_eq!(
+ hosts(&format!("https://{host}/example")),
+ vec![host],
+ "should reject unreviewed standard-host variants"
+ );
+ }
+ }
+ }
+
#[test]
fn disallowed_reports() {
assert_eq!(hosts("see https://test.com"), vec!["test.com"]);
diff --git a/crates/trusted-server-cli/src/commands/mod.rs b/crates/trusted-server-cli/src/commands/mod.rs
index 0f4354f007..4a5ff7b707 100644
--- a/crates/trusted-server-cli/src/commands/mod.rs
+++ b/crates/trusted-server-cli/src/commands/mod.rs
@@ -9,3 +9,6 @@ pub(crate) mod pbs;
pub mod cache;
pub mod dev;
pub mod origin;
+
+#[cfg(unix)]
+pub(crate) mod release;
diff --git a/crates/trusted-server-cli/src/commands/release.rs b/crates/trusted-server-cli/src/commands/release.rs
new file mode 100644
index 0000000000..13335ccacf
--- /dev/null
+++ b/crates/trusted-server-cli/src/commands/release.rs
@@ -0,0 +1,35 @@
+//! Read-only verification of official release assets.
+
+use clap::{Args, Subcommand};
+
+use crate::release;
+
+/// Read-only operations over the fixed official release cohort.
+#[derive(Debug, Subcommand)]
+pub(crate) enum ReleaseCommand {
+ /// Verify an official release asset against an independently approved commit.
+ Verify(VerifyArgs),
+}
+
+/// An independently approved source and the exact release asset to verify.
+#[derive(Debug, Args)]
+pub(crate) struct VerifyArgs {
+ /// Exact release tag, such as v1.2.0.
+ tag: String,
+ /// Exact executable name in the embedded release cohort.
+ #[arg(long)]
+ asset: String,
+ /// Independently approved full, lowercase source commit.
+ #[arg(long)]
+ expected_source_commit: String,
+}
+
+/// Runs release verification without invoking provider or build operations.
+///
+/// # Errors
+///
+/// Returns a diagnostic when any input, evidence or publication check fails.
+pub(crate) fn run(command: ReleaseCommand) -> Result<(), String> {
+ let ReleaseCommand::Verify(args) = command;
+ release::verify(&args.tag, &args.asset, &args.expected_source_commit)
+}
diff --git a/crates/trusted-server-cli/src/lib.rs b/crates/trusted-server-cli/src/lib.rs
index 24e001d853..1bdfc9d931 100644
--- a/crates/trusted-server-cli/src/lib.rs
+++ b/crates/trusted-server-cli/src/lib.rs
@@ -6,6 +6,17 @@ mod app_config;
mod error;
#[cfg(not(target_arch = "wasm32"))]
mod prebid_bundle;
+// Consumer-side release attestation verification. Unix-only because the gh
+// runner terminates whole process groups. The public command is read-only.
+#[cfg(all(unix, not(target_arch = "wasm32")))]
+#[cfg_attr(
+ not(test),
+ expect(
+ dead_code,
+ reason = "private offline and future action-consumption helpers have no public command surface"
+ )
+)]
+mod release;
#[cfg(not(target_arch = "wasm32"))]
mod run;
#[cfg(not(target_arch = "wasm32"))]
@@ -14,7 +25,7 @@ mod tls;
mod url_guard;
#[cfg(not(target_arch = "wasm32"))]
-pub use run::{RunOutcome, run_from_env};
+pub use run::{BUILD_INFO, RunOutcome, run_from_env};
// Every `ts` subcommand's implementation lives under `commands/`. The
// `ts dev` group is available on every host target; `ts dev lint` and
diff --git a/crates/trusted-server-cli/src/prebid_bundle.rs b/crates/trusted-server-cli/src/prebid_bundle.rs
index 609c950531..1ad5d605b1 100644
--- a/crates/trusted-server-cli/src/prebid_bundle.rs
+++ b/crates/trusted-server-cli/src/prebid_bundle.rs
@@ -620,6 +620,18 @@ fn ensure_file_exists(path: &Path, description: &str) -> CliResult<()> {
}
fn find_js_lib_dir(start: &Path) -> CliResult {
+ #[cfg(not(trusted_server_release_build))]
+ let fallback = Some(
+ PathBuf::from(env!("CARGO_MANIFEST_DIR"))
+ .join("../..")
+ .join("crates/trusted-server-js/lib"),
+ );
+ #[cfg(trusted_server_release_build)]
+ let fallback: Option = None;
+ find_js_lib_dir_with_fallback(start, fallback.as_deref())
+}
+
+fn find_js_lib_dir_with_fallback(start: &Path, fallback: Option<&Path>) -> CliResult {
for ancestor in start.ancestors() {
let candidate = ancestor.join("crates/trusted-server-js/lib");
if is_js_lib_dir(&candidate) {
@@ -627,11 +639,9 @@ fn find_js_lib_dir(start: &Path) -> CliResult {
}
}
- let manifest_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
- let candidate = manifest_dir
- .join("../..")
- .join("crates/trusted-server-js/lib");
- if is_js_lib_dir(&candidate) {
+ if let Some(candidate) = fallback
+ && is_js_lib_dir(candidate)
+ {
return candidate.canonicalize().map_err(|error| {
report_error(format!(
"failed to resolve JS library directory {}: {error}",
@@ -835,6 +845,71 @@ fn write_atomic(path: &Path, contents: &str) -> CliResult<()> {
mod tests {
use super::*;
+ #[test]
+ fn resource_discovery_without_fallback_requires_runtime_checkout() {
+ let directory = tempfile::tempdir().expect("should create outside checkout fixture");
+ let error = find_js_lib_dir_with_fallback(directory.path(), None)
+ .expect_err("should require runtime checkout when fallback is absent");
+ assert!(
+ error
+ .to_string()
+ .contains("run `ts prebid client` from the Trusted Server repository"),
+ "should explain the missing source prerequisites"
+ );
+ let resources = directory.path().join("crates/trusted-server-js/lib");
+ fs::create_dir_all(&resources).expect("should create runtime resources");
+ fs::write(resources.join("package.json"), "{}").expect("should create package fixture");
+ fs::write(resources.join("build-prebid-external.mjs"), "")
+ .expect("should create builder fixture");
+ let nested = directory.path().join("nested");
+ fs::create_dir(&nested).expect("should create nested current directory");
+ assert_eq!(
+ find_js_lib_dir_with_fallback(&nested, None)
+ .expect("should discover actual runtime checkout"),
+ resources,
+ "should discover runtime ancestor resources"
+ );
+ }
+
+ #[test]
+ fn resource_discovery_uses_explicit_development_fallback() {
+ let directory = tempfile::tempdir().expect("should create fixture");
+ let resources = directory.path().join("development-lib");
+ fs::create_dir(&resources).expect("should create development fallback");
+ fs::write(resources.join("package.json"), "{}").expect("should create package fixture");
+ fs::write(resources.join("build-prebid-external.mjs"), "")
+ .expect("should create builder fixture");
+ assert_eq!(
+ find_js_lib_dir_with_fallback(directory.path(), Some(&resources))
+ .expect("should use explicit development fallback"),
+ resources
+ .canonicalize()
+ .expect("should canonicalize fallback"),
+ "should preserve development fallback behavior"
+ );
+ }
+
+ #[test]
+ fn actual_resource_discovery_uses_owning_release_cfg() {
+ let directory = tempfile::tempdir().expect("should create outside checkout fixture");
+ let result = find_js_lib_dir(directory.path());
+ if cfg!(trusted_server_release_build) {
+ let error =
+ result.expect_err("should exclude the compiled developer checkout in release mode");
+ assert!(
+ error
+ .to_string()
+ .contains("run `ts prebid client` from the Trusted Server repository"),
+ "should identify release source prerequisites"
+ );
+ } else {
+ assert!(
+ result.is_ok(),
+ "should retain the compiled checkout fallback in development"
+ );
+ }
+ }
+
fn managed_config(managed: &str, user_id: &str) -> String {
format!(
r#"
diff --git a/crates/trusted-server-cli/src/release/artifact.rs b/crates/trusted-server-cli/src/release/artifact.rs
new file mode 100644
index 0000000000..3db444a858
--- /dev/null
+++ b/crates/trusted-server-cli/src/release/artifact.rs
@@ -0,0 +1,586 @@
+//! Retained release asset bytes and the read-only verification report.
+//!
+//! Downloads stream into a private, unlinked temporary file while the role
+//! byte limit is enforced chunk by chunk, so an oversize executable is
+//! rejected mid-transfer instead of after it was fully written. A
+//! [`RetainedAsset`] is only a measured download; a [`VerifiedReleaseAsset`]
+//! is constructed exclusively from a [`PublicationVerified`] state that names
+//! the same asset, digest and length, and is re-hashed before every later use.
+//!
+//! Nothing here authorizes deployment: [`VerificationReport`] is a read-only
+//! summary, and action-scoped authorization belongs to a later integration.
+
+use std::fs::{File, OpenOptions};
+use std::io::{Read as _, Seek as _, SeekFrom, Write as _};
+use std::os::unix::fs::OpenOptionsExt as _;
+use std::path::{Path, PathBuf};
+use std::time::Instant;
+
+use error_stack::Report;
+use sha2::{Digest as _, Sha256};
+
+use super::bounds::{self, AttemptDeadline, Cancellation};
+use super::error::{ReleaseError, report};
+use super::evidence::ComparisonOutcome;
+use super::github::PublicationVerified;
+
+/// Read buffer used when re-hashing a retained file.
+const REHASH_CHUNK: usize = 64 * 1024;
+
+/// The byte-limit class of a release asset.
+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+pub(crate) enum AssetRole {
+ /// A cohort executable (512 MiB).
+ Executable,
+ /// A Sigstore bundle or build companion (32 MiB).
+ Companion,
+ /// An SPDX SBOM document (16 MiB).
+ Spdx,
+ /// The index, JS inventory or `SHA256SUMS` (1 MiB).
+ Metadata,
+}
+
+impl AssetRole {
+ /// Returns the R-BOUNDS byte limit for this role.
+ pub(crate) const fn limit(self) -> u64 {
+ match self {
+ Self::Executable => bounds::EXECUTABLE_BYTES,
+ Self::Companion => bounds::COMPANION_BYTES,
+ Self::Spdx => bounds::SPDX_BYTES,
+ Self::Metadata => bounds::METADATA_BYTES,
+ }
+ }
+}
+
+/// The exact SHA-256 and length of distributed bytes.
+#[derive(Clone, Debug, PartialEq, Eq)]
+pub(crate) struct AssetMeasurement {
+ sha256: String,
+ length: u64,
+}
+
+impl AssetMeasurement {
+ /// Measures exact in-memory bytes without reserializing them.
+ pub(crate) fn of_bytes(bytes: &[u8]) -> Self {
+ Self {
+ sha256: hex::encode(Sha256::digest(bytes)),
+ length: bytes.len() as u64,
+ }
+ }
+
+ /// Records a length and digest declared by already verified evidence.
+ ///
+ /// Used only for expectations; retained bytes are always re-hashed
+ /// before a [`VerifiedReleaseAsset`] is constructed.
+ pub(crate) fn declared(sha256: &str, length: u64) -> Self {
+ Self {
+ sha256: sha256.to_owned(),
+ length,
+ }
+ }
+
+ /// Returns the lowercase hexadecimal SHA-256.
+ pub(crate) fn sha256(&self) -> &str {
+ &self.sha256
+ }
+
+ /// Returns the byte length.
+ pub(crate) fn length(&self) -> u64 {
+ self.length
+ }
+}
+
+/// A download in progress into a private retained file.
+#[derive(Debug)]
+pub(crate) struct RetainedDownload {
+ name: String,
+ role: AssetRole,
+ file: File,
+ hasher: Sha256,
+ length: u64,
+}
+
+impl RetainedDownload {
+ /// Starts a download into a new anonymous, owner-only temporary file.
+ ///
+ /// # Errors
+ ///
+ /// Returns [`ReleaseError::Asset`] if the private file cannot be created.
+ pub(crate) fn start(name: &str, role: AssetRole) -> Result> {
+ let file = tempfile::tempfile().map_err(|source| report(ReleaseError::Asset, source))?;
+ Ok(Self {
+ name: name.to_owned(),
+ role,
+ file,
+ hasher: Sha256::new(),
+ length: 0,
+ })
+ }
+
+ /// Appends one received chunk, enforcing the role limit before writing.
+ ///
+ /// # Errors
+ ///
+ /// Returns [`ReleaseError::Asset`] once the total would exceed the role
+ /// limit (the transfer must then be aborted) or the write fails.
+ pub(crate) fn push(&mut self, chunk: &[u8]) -> Result<(), Report> {
+ let remaining = self.role.limit().saturating_sub(self.length);
+ if chunk.len() as u64 > remaining {
+ return Err(report(
+ ReleaseError::Asset,
+ format!(
+ "asset `{}` exceeds its {} byte role limit; download aborted",
+ self.name,
+ self.role.limit()
+ ),
+ ));
+ }
+ self.file
+ .write_all(chunk)
+ .map_err(|source| report(ReleaseError::Asset, source))?;
+ self.hasher.update(chunk);
+ self.length += chunk.len() as u64;
+ Ok(())
+ }
+
+ /// Completes the download and returns the measured retained asset.
+ ///
+ /// # Errors
+ ///
+ /// Returns [`ReleaseError::Asset`] if the file cannot be flushed.
+ pub(crate) fn finish(mut self) -> Result> {
+ self.file
+ .flush()
+ .map_err(|source| report(ReleaseError::Asset, source))?;
+ Ok(RetainedAsset {
+ name: self.name,
+ role: self.role,
+ file: self.file,
+ measurement: AssetMeasurement {
+ sha256: hex::encode(self.hasher.finalize()),
+ length: self.length,
+ },
+ })
+ }
+}
+
+/// Streams a response body into a retained file under the role limit.
+///
+/// A declared `Content-Length` above the limit is rejected before any byte
+/// is read; the streamed total is enforced independently while receiving.
+///
+/// # Errors
+///
+/// Returns [`ReleaseError::Asset`] for oversize or failed transfers and
+/// [`ReleaseError::Timeout`] when the attempt deadline elapses.
+pub(crate) async fn stream_into(
+ mut response: reqwest::Response,
+ mut download: RetainedDownload,
+ deadline: AttemptDeadline,
+) -> Result> {
+ if response
+ .content_length()
+ .is_some_and(|length| length > download.role.limit())
+ {
+ return Err(report(
+ ReleaseError::Asset,
+ format!(
+ "asset `{}` declares more than its role limit",
+ download.name
+ ),
+ ));
+ }
+ let transfer = async {
+ while let Some(chunk) = response
+ .chunk()
+ .await
+ .map_err(|source| report(ReleaseError::Asset, source.without_url()))?
+ {
+ download.push(&chunk)?;
+ }
+ download.finish()
+ };
+ tokio::time::timeout_at(tokio::time::Instant::from_std(deadline.instant()), transfer)
+ .await
+ .map_err(|_| {
+ report(
+ ReleaseError::Timeout,
+ "asset download exceeded the deadline",
+ )
+ })?
+}
+
+/// A fully downloaded, measured but not yet verified release asset.
+#[derive(Debug)]
+pub(crate) struct RetainedAsset {
+ name: String,
+ role: AssetRole,
+ file: File,
+ measurement: AssetMeasurement,
+}
+
+impl RetainedAsset {
+ /// Copies retained bytes to a canonical private filename for gh snapshots.
+ ///
+ /// # Errors
+ ///
+ /// Rejects unsafe names, collisions, changed bytes, limits or cancellation.
+ pub(crate) fn materialize(
+ &mut self,
+ directory: &Path,
+ deadline: AttemptDeadline,
+ cancellation: &Cancellation,
+ ) -> Result> {
+ if !super::documents::safe_name(&self.name) || !directory.is_absolute() {
+ return Err(report(
+ ReleaseError::Asset,
+ "unsafe retained materialization path",
+ ));
+ }
+ let path = directory.join(&self.name);
+ let mut output = OpenOptions::new()
+ .write(true)
+ .create_new(true)
+ .mode(0o600)
+ .open(&path)
+ .map_err(|error| report(ReleaseError::Asset, error))?;
+ self.file
+ .seek(SeekFrom::Start(0))
+ .map_err(|error| report(ReleaseError::Asset, error))?;
+ let mut hasher = Sha256::new();
+ let mut length = 0_u64;
+ let mut buffer = [0_u8; REHASH_CHUNK];
+ loop {
+ if cancellation.is_cancelled() {
+ return Err(report(ReleaseError::Cancelled, "retained copy cancelled"));
+ }
+ if Instant::now() >= deadline.instant() {
+ return Err(report(
+ ReleaseError::Timeout,
+ "retained copy exceeded deadline",
+ ));
+ }
+ let read = self
+ .file
+ .read(&mut buffer)
+ .map_err(|error| report(ReleaseError::Asset, error))?;
+ if read == 0 {
+ break;
+ }
+ length += read as u64;
+ if length > self.role.limit() {
+ return Err(report(
+ ReleaseError::Asset,
+ "retained copy exceeds role limit",
+ ));
+ }
+ let chunk = &buffer[..read];
+ output
+ .write_all(chunk)
+ .map_err(|error| report(ReleaseError::Asset, error))?;
+ hasher.update(chunk);
+ }
+ let measurement = AssetMeasurement::declared(&hex::encode(hasher.finalize()), length);
+ if measurement != self.measurement {
+ return Err(report(
+ ReleaseError::Asset,
+ "retained bytes changed before gh verification",
+ ));
+ }
+ output
+ .flush()
+ .map_err(|error| report(ReleaseError::Asset, error))?;
+ Ok(path)
+ }
+
+ /// Returns the release asset name.
+ pub(crate) fn name(&self) -> &str {
+ &self.name
+ }
+
+ /// Returns the measured digest and length.
+ pub(crate) fn measurement(&self) -> &AssetMeasurement {
+ &self.measurement
+ }
+
+ /// Re-reads the retained bytes of a non-executable document after
+ /// re-checking their digest.
+ ///
+ /// # Errors
+ ///
+ /// Returns [`ReleaseError::Asset`] for executables (never buffered whole)
+ /// or if the retained bytes changed.
+ pub(crate) fn document_bytes(&mut self) -> Result, Report> {
+ if self.role == AssetRole::Executable {
+ return Err(report(
+ ReleaseError::Asset,
+ "executables are verified by streamed digest, not buffered",
+ ));
+ }
+ let bytes = read_bounded(&mut self.file, self.role.limit())?;
+ if AssetMeasurement::of_bytes(&bytes) != self.measurement {
+ return Err(report(
+ ReleaseError::Asset,
+ format!("retained asset `{}` changed after download", self.name),
+ ));
+ }
+ Ok(bytes)
+ }
+}
+
+fn read_bounded(file: &mut File, limit: u64) -> Result, Report> {
+ file.seek(SeekFrom::Start(0))
+ .map_err(|source| report(ReleaseError::Asset, source))?;
+ let mut bytes = Vec::new();
+ file.take(limit + 1)
+ .read_to_end(&mut bytes)
+ .map_err(|source| report(ReleaseError::Asset, source))?;
+ if bytes.len() as u64 > limit {
+ return Err(report(
+ ReleaseError::Asset,
+ "retained asset grew past its role limit",
+ ));
+ }
+ Ok(bytes)
+}
+
+fn rehash(
+ file: &mut File,
+ limit: u64,
+ deadline: AttemptDeadline,
+ cancellation: &Cancellation,
+) -> Result> {
+ file.seek(SeekFrom::Start(0))
+ .map_err(|source| report(ReleaseError::Asset, source))?;
+ let mut hasher = Sha256::new();
+ let mut length = 0_u64;
+ let mut buffer = vec![0_u8; REHASH_CHUNK];
+ loop {
+ check_attempt(deadline, cancellation)?;
+ let read = file
+ .read(&mut buffer)
+ .map_err(|source| report(ReleaseError::Asset, source))?;
+ if read == 0 {
+ break;
+ }
+ length += read as u64;
+ if length > limit {
+ return Err(report(
+ ReleaseError::Asset,
+ "retained asset grew past its role limit",
+ ));
+ }
+ hasher.update(buffer.get(..read).unwrap_or_default());
+ }
+ Ok(AssetMeasurement {
+ sha256: hex::encode(hasher.finalize()),
+ length,
+ })
+}
+
+/// Checks the shared deadline and cancellation before file work or success.
+pub(crate) fn check_attempt(
+ deadline: AttemptDeadline,
+ cancellation: &Cancellation,
+) -> Result<(), Report> {
+ if cancellation.is_cancelled() {
+ return Err(report(
+ ReleaseError::Cancelled,
+ "release verification cancelled",
+ ));
+ }
+ if Instant::now() >= deadline.instant() {
+ return Err(report(
+ ReleaseError::Timeout,
+ "release verification deadline elapsed",
+ ));
+ }
+ Ok(())
+}
+
+/// A retained release executable whose bytes match verified, published
+/// evidence.
+///
+/// Constructed only by [`VerifiedReleaseAsset::from_publication_bounded`]; an
+/// asserted path or hash cannot build one.
+#[derive(Debug)]
+pub(crate) struct VerifiedReleaseAsset {
+ name: String,
+ file: File,
+ measurement: AssetMeasurement,
+ assurance_profile: String,
+ comparison: ComparisonOutcome,
+ publication_index: AssetMeasurement,
+}
+
+impl VerifiedReleaseAsset {
+ /// Test compatibility wrapper; production must pass its attempt bounds.
+ #[cfg(test)]
+ pub(crate) fn from_publication(
+ publication: &PublicationVerified,
+ asset: RetainedAsset,
+ ) -> Result> {
+ Self::from_publication_bounded(
+ publication,
+ asset,
+ AttemptDeadline::start(),
+ &Cancellation::default(),
+ )
+ }
+
+ /// Binds a retained executable to the publication-verified evidence.
+ ///
+ /// # Errors
+ ///
+ /// Returns [`ReleaseError::Asset`] unless the asset is a retained
+ /// executable consumed by `publication` whose re-hashed bytes equal the
+ /// verified digest and length.
+ pub(crate) fn from_publication_bounded(
+ publication: &PublicationVerified,
+ mut asset: RetainedAsset,
+ deadline: AttemptDeadline,
+ cancellation: &Cancellation,
+ ) -> Result> {
+ let consumed = publication
+ .build()
+ .consumed()
+ .iter()
+ .find(|consumed| consumed.name() == asset.name)
+ .ok_or_else(|| {
+ report(
+ ReleaseError::Asset,
+ format!("asset `{}` was not consumed by verification", asset.name),
+ )
+ })?;
+ if asset.role != AssetRole::Executable {
+ return Err(report(
+ ReleaseError::Asset,
+ "only retained executables become verified release assets",
+ ));
+ }
+ check_attempt(deadline, cancellation)?;
+ let measured = rehash(
+ &mut asset.file,
+ AssetRole::Executable.limit(),
+ deadline,
+ cancellation,
+ )?;
+ if measured != asset.measurement || &measured != consumed.measurement() {
+ return Err(report(
+ ReleaseError::Asset,
+ format!("asset `{}` does not match verified evidence", asset.name),
+ ));
+ }
+ Ok(Self {
+ name: asset.name,
+ file: asset.file,
+ measurement: measured,
+ assurance_profile: consumed.assurance_profile().to_owned(),
+ comparison: consumed.comparison(),
+ publication_index: publication.build().index_measurement().clone(),
+ })
+ }
+
+ /// Re-hashes the retained bytes; call before every later use.
+ ///
+ /// # Errors
+ ///
+ /// Returns [`ReleaseError::Asset`] if the retained bytes changed.
+ pub(crate) fn rehash_bounded(
+ &mut self,
+ deadline: AttemptDeadline,
+ cancellation: &Cancellation,
+ ) -> Result<&AssetMeasurement, Report> {
+ let measured = rehash(
+ &mut self.file,
+ AssetRole::Executable.limit(),
+ deadline,
+ cancellation,
+ )?;
+ if measured != self.measurement {
+ return Err(report(
+ ReleaseError::Asset,
+ format!("verified asset `{}` changed after verification", self.name),
+ ));
+ }
+ Ok(&self.measurement)
+ }
+
+ /// Returns the asset name.
+ pub(crate) fn name(&self) -> &str {
+ &self.name
+ }
+}
+
+/// Read-only summary of one complete online verification.
+///
+/// It is not a deployment authorization and cannot construct one.
+#[derive(Clone, Debug, PartialEq, Eq)]
+pub(crate) struct VerificationReport {
+ pub(crate) consumer_policy_version: u64,
+ pub(crate) consumer_policy_sha256: String,
+ pub(crate) producer_policy_version: u64,
+ pub(crate) producer_policy_sha256: String,
+ pub(crate) repository: &'static str,
+ pub(crate) tag: String,
+ pub(crate) source_commit: String,
+ pub(crate) asset: String,
+ pub(crate) asset_sha256: String,
+ pub(crate) asset_length: u64,
+ pub(crate) assurance_profile: String,
+ pub(crate) run_invocation: String,
+ /// Informational rebuild comparison; never an authorization input.
+ pub(crate) comparison: ComparisonOutcome,
+}
+
+impl VerificationReport {
+ /// Test compatibility wrapper; production must pass its attempt bounds.
+ #[cfg(test)]
+ pub(crate) fn new(
+ publication: &PublicationVerified,
+ asset: &mut VerifiedReleaseAsset,
+ ) -> Result> {
+ Self::new_bounded(
+ publication,
+ asset,
+ AttemptDeadline::start(),
+ &Cancellation::default(),
+ )
+ }
+ /// Summarizes a publication-verified asset after re-hashing it.
+ ///
+ /// # Errors
+ ///
+ /// Returns [`ReleaseError::Asset`] if the retained bytes changed.
+ pub(crate) fn new_bounded(
+ publication: &PublicationVerified,
+ asset: &mut VerifiedReleaseAsset,
+ deadline: AttemptDeadline,
+ cancellation: &Cancellation,
+ ) -> Result> {
+ let measurement = asset.rehash_bounded(deadline, cancellation)?.clone();
+ let build = publication.build();
+ if &asset.publication_index != build.index_measurement() {
+ return Err(report(
+ ReleaseError::Asset,
+ "asset belongs to another publication",
+ ));
+ }
+ check_attempt(deadline, cancellation)?;
+ Ok(Self {
+ consumer_policy_version: build.consumer_policy_version(),
+ consumer_policy_sha256: build.consumer_policy_sha256().to_owned(),
+ producer_policy_version: build.index().policy_version,
+ producer_policy_sha256: build.index().policy_sha256.clone(),
+ repository: super::policy::REPOSITORY,
+ tag: build.release().tag().as_str().to_owned(),
+ source_commit: build.release().commit().as_str().to_owned(),
+ asset: asset.name.clone(),
+ asset_sha256: measurement.sha256().to_owned(),
+ asset_length: measurement.length(),
+ assurance_profile: asset.assurance_profile.clone(),
+ run_invocation: build.run_invocation().to_owned(),
+ comparison: asset.comparison,
+ })
+ }
+}
diff --git a/crates/trusted-server-cli/src/release/bounds.rs b/crates/trusted-server-cli/src/release/bounds.rs
new file mode 100644
index 0000000000..35c9561263
--- /dev/null
+++ b/crates/trusted-server-cli/src/release/bounds.rs
@@ -0,0 +1,115 @@
+//! Fixed R-BOUNDS resource limits.
+//!
+//! These values mirror the embedded policy `bounds` object, which
+//! [`super::policy`] requires to match exactly. They change only through a
+//! reviewed policy update.
+
+use std::sync::Arc;
+use std::sync::atomic::{AtomicBool, Ordering};
+use std::time::{Duration, Instant};
+
+const MIB: u64 = 1024 * 1024;
+
+/// Maximum release-index bytes.
+pub(crate) const INDEX_BYTES: u64 = MIB;
+/// Maximum release-policy bytes, embedded or approved-source.
+pub(crate) const POLICY_BYTES: u64 = MIB;
+/// Maximum JS inventory bytes.
+pub(crate) const INVENTORY_BYTES: u64 = MIB;
+/// Maximum generic metadata bytes.
+pub(crate) const METADATA_BYTES: u64 = MIB;
+/// Maximum Sigstore bundle bytes.
+pub(crate) const BUNDLE_BYTES: u64 = 32 * MIB;
+/// Maximum build companion bytes.
+pub(crate) const COMPANION_BYTES: u64 = 32 * MIB;
+/// Maximum SPDX SBOM bytes (the pinned action's limit).
+pub(crate) const SPDX_BYTES: u64 = 16 * MIB;
+/// Maximum trusted-root snapshot bytes.
+pub(crate) const TRUSTED_ROOTS_BYTES: u64 = MIB;
+/// Maximum executable bytes.
+pub(crate) const EXECUTABLE_BYTES: u64 = 512 * MIB;
+/// Maximum JSON container nesting, counting the root.
+pub(crate) const JSON_DEPTH: usize = 64;
+/// Exact number of executables in the cohort.
+pub(crate) const COHORT_EXECUTABLES: usize = 4;
+/// Exact number of matching attestation results accepted per bundle.
+pub(crate) const MATCHING_ATTESTATIONS_PER_BUNDLE: usize = 1;
+/// Per-child wall-clock deadline in seconds.
+pub(crate) const CHILD_SECONDS: u64 = 120;
+/// Whole verification attempt deadline in seconds.
+pub(crate) const ATTEMPT_SECONDS: u64 = 900;
+/// Maximum child stdout bytes.
+pub(crate) const STDOUT_BYTES: u64 = 128 * MIB;
+/// Maximum child stderr bytes.
+pub(crate) const STDERR_BYTES: u64 = MIB;
+
+/// Bounded wait for pipe drains after the child process group is killed.
+///
+/// This is a runner-internal liveness bound, not an R-BOUNDS policy value: a
+/// killed group closes its pipes promptly, so anything still holding them
+/// after this grace has escaped the group and is reported as such.
+const KILL_GRACE: Duration = Duration::from_secs(2);
+
+/// Per-child process limits.
+#[derive(Clone, Copy, Debug)]
+pub(crate) struct ProcessBounds {
+ pub(crate) child_timeout: Duration,
+ pub(crate) stdout_bytes: u64,
+ pub(crate) stderr_bytes: u64,
+ pub(crate) kill_grace: Duration,
+}
+
+impl ProcessBounds {
+ /// Returns the fixed production limits.
+ pub(crate) const fn production() -> Self {
+ Self {
+ child_timeout: Duration::from_secs(CHILD_SECONDS),
+ stdout_bytes: STDOUT_BYTES,
+ stderr_bytes: STDERR_BYTES,
+ kill_grace: KILL_GRACE,
+ }
+ }
+}
+
+/// The overall deadline shared by every step of one verification attempt.
+#[derive(Clone, Copy, Debug)]
+pub(crate) struct AttemptDeadline(Instant);
+
+impl AttemptDeadline {
+ /// Starts the fixed 15-minute attempt deadline now.
+ pub(crate) fn start() -> Self {
+ Self(Instant::now() + Duration::from_secs(ATTEMPT_SECONDS))
+ }
+
+ /// Uses an explicit instant, for callers that already hold a deadline.
+ pub(crate) const fn at(instant: Instant) -> Self {
+ Self(instant)
+ }
+
+ /// Returns the deadline instant.
+ pub(crate) const fn instant(self) -> Instant {
+ self.0
+ }
+}
+
+/// A cancellation flag shared between the caller and running children.
+#[derive(Clone, Debug, Default)]
+pub(crate) struct Cancellation(Arc);
+
+impl Cancellation {
+ /// Waits for cancellation while leaving stalled HTTP futures interruptible.
+ pub(crate) async fn cancelled(&self) {
+ while !self.is_cancelled() {
+ tokio::time::sleep(Duration::from_millis(25)).await;
+ }
+ }
+ /// Requests cancellation of every child observing this flag.
+ pub(crate) fn cancel(&self) {
+ self.0.store(true, Ordering::Release);
+ }
+
+ /// Returns whether cancellation was requested.
+ pub(crate) fn is_cancelled(&self) -> bool {
+ self.0.load(Ordering::Acquire)
+ }
+}
diff --git a/crates/trusted-server-cli/src/release/documents.rs b/crates/trusted-server-cli/src/release/documents.rs
new file mode 100644
index 0000000000..0b80dd2492
--- /dev/null
+++ b/crates/trusted-server-cli/src/release/documents.rs
@@ -0,0 +1,716 @@
+//! Byte-first strict JSON parsing and closed release-index validation.
+//!
+//! This mirrors the producer's strict parser without depending on the release
+//! crate: role byte limits apply before decoding, then BOM, UTF-8, lexical
+//! number range, duplicate keys (rejected before insertion), nesting depth and
+//! trailing data are checked. Hashes always cover the exact distributed bytes.
+//!
+//! [`parse_release_index`] enforces the closed field set and every semantic
+//! rule for the fixed cohort, and additionally evaluates the retained
+//! governing release-index schema with explicit Draft 2020-12, offline.
+
+use std::collections::BTreeSet;
+use std::fmt;
+use std::sync::LazyLock;
+
+use error_stack::Report;
+use jsonschema::Draft;
+use regex::Regex;
+use serde::Deserialize;
+use serde::de::{DeserializeSeed, MapAccess, SeqAccess, Visitor};
+use serde_json::{Map, Number, Value};
+use sha2::{Digest as _, Sha256};
+
+use super::bounds;
+use super::error::{ReleaseError, report};
+use super::policy::{ConsumerPolicy, DeclaredIdentity, FixedArtifact, check_capability_vocabulary};
+
+/// Largest integer every JSON implementation represents exactly.
+const SAFE_INTEGER_MAX: u64 = 9_007_199_254_740_991;
+/// Fixed name of the shared JS inventory asset.
+const INVENTORY_NAME: &str = "tsjs-inventory.json";
+/// Maximum flat asset name length from the governing schema.
+const NAME_MAX: usize = 160;
+/// Maximum release tag length from the governing schema.
+const TAG_MAX: usize = 128;
+
+static TAG_PATTERN: LazyLock = LazyLock::new(|| {
+ Regex::new(
+ r"^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(?:-(?:0|[1-9][0-9]*|[0-9]*[A-Za-z-][0-9A-Za-z-]*)(?:\.(?:0|[1-9][0-9]*|[0-9]*[A-Za-z-][0-9A-Za-z-]*))*)?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$",
+ )
+ .expect("should compile the reviewed release tag pattern")
+});
+
+/// Byte-limit class selected before a document is parsed.
+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+pub(crate) enum Role {
+ Index,
+ Policy,
+ Inventory,
+ Companion,
+ Bundle,
+ Spdx,
+ GhOutput,
+ /// Bounded official REST metadata responses.
+ Metadata,
+}
+
+impl Role {
+ /// Returns the R-BOUNDS byte limit for this role.
+ pub(crate) const fn limit(self) -> u64 {
+ match self {
+ Self::Index => bounds::INDEX_BYTES,
+ Self::Policy => bounds::POLICY_BYTES,
+ Self::Inventory => bounds::INVENTORY_BYTES,
+ Self::Companion => bounds::COMPANION_BYTES,
+ Self::Bundle => bounds::BUNDLE_BYTES,
+ Self::Spdx => bounds::SPDX_BYTES,
+ Self::GhOutput => bounds::STDOUT_BYTES,
+ Self::Metadata => bounds::METADATA_BYTES,
+ }
+ }
+}
+
+/// Parses bounded strict JSON with unique keys and finite in-range numbers.
+///
+/// # Errors
+///
+/// Returns [`ReleaseError::Document`] for oversize input, a BOM, invalid UTF-8,
+/// out-of-range numbers, duplicate keys, nesting beyond 64 levels or trailing
+/// data.
+pub(crate) fn parse_json(bytes: &[u8], role: Role) -> Result> {
+ if bytes.len() as u64 > role.limit() {
+ return Err(report(
+ ReleaseError::Document,
+ "document exceeds role byte limit",
+ ));
+ }
+ if bytes.starts_with(b"\xef\xbb\xbf") {
+ return Err(report(
+ ReleaseError::Document,
+ "document must not start with a BOM",
+ ));
+ }
+ let text = std::str::from_utf8(bytes)
+ .map_err(|_| report(ReleaseError::Document, "document must be UTF-8"))?;
+ validate_numbers(text)?;
+ let mut parser = serde_json::Deserializer::from_str(text);
+ let value = StrictSeed { depth: 1 }
+ .deserialize(&mut parser)
+ .map_err(|source| report(ReleaseError::Document, source))?;
+ parser
+ .end()
+ .map_err(|source| report(ReleaseError::Document, source))?;
+ Ok(value)
+}
+
+/// Returns the lowercase hexadecimal SHA-256 of the exact bytes.
+pub(crate) fn sha256_hex(bytes: &[u8]) -> String {
+ hex::encode(Sha256::digest(bytes))
+}
+
+/// Returns whether `value` is exactly `length` lowercase hexadecimal digits.
+pub(crate) fn lower_hex(value: &str, length: usize) -> bool {
+ value.len() == length
+ && value
+ .bytes()
+ .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
+}
+
+/// Returns whether `name` is a safe flat asset name.
+///
+/// Rejects path separators, `..`, empty segments, control characters and
+/// anything outside the governing schema's name pattern.
+pub(crate) fn safe_name(name: &str) -> bool {
+ let mut parts = name.split('.');
+ let first = parts.next().unwrap_or_default();
+ name.len() <= NAME_MAX
+ && first
+ .as_bytes()
+ .first()
+ .is_some_and(u8::is_ascii_alphanumeric)
+ && std::iter::once(first).chain(parts).all(|part| {
+ !part.is_empty()
+ && part
+ .bytes()
+ .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-'))
+ })
+}
+
+/// An independently approved full 40-character lowercase source commit.
+#[derive(Clone, Debug, PartialEq, Eq)]
+pub(crate) struct ApprovedCommit(String);
+
+impl ApprovedCommit {
+ /// Parses an approved commit supplied by trusted consumer approval.
+ ///
+ /// # Errors
+ ///
+ /// Returns [`ReleaseError::Policy`] unless the value is 40 lowercase hex digits.
+ pub(crate) fn parse(value: &str) -> Result> {
+ if !lower_hex(value, 40) {
+ return Err(report(
+ ReleaseError::Policy,
+ "approved source commit must be 40 lowercase hexadecimal digits",
+ ));
+ }
+ Ok(Self(value.to_owned()))
+ }
+
+ /// Returns the commit text.
+ pub(crate) fn as_str(&self) -> &str {
+ &self.0
+ }
+}
+
+/// A release tag matching the governing `SemVer` `v`-prefixed pattern.
+#[derive(Clone, Debug, PartialEq, Eq)]
+pub(crate) struct ReleaseTag(String);
+
+impl ReleaseTag {
+ /// Parses a release tag.
+ ///
+ /// # Errors
+ ///
+ /// Returns [`ReleaseError::Policy`] for tags outside the reviewed pattern.
+ pub(crate) fn parse(value: &str) -> Result> {
+ if value.len() > TAG_MAX || !TAG_PATTERN.is_match(value) {
+ return Err(report(
+ ReleaseError::Policy,
+ "release tag must be a v-prefixed SemVer tag",
+ ));
+ }
+ Ok(Self(value.to_owned()))
+ }
+
+ /// Returns the tag text.
+ pub(crate) fn as_str(&self) -> &str {
+ &self.0
+ }
+}
+
+/// The tag and source commit approved independently of downloaded metadata.
+#[derive(Clone, Debug, PartialEq, Eq)]
+pub(crate) struct ApprovedRelease {
+ tag: ReleaseTag,
+ commit: ApprovedCommit,
+}
+
+impl ApprovedRelease {
+ /// Pairs an approved tag with its approved source commit.
+ pub(crate) fn new(tag: ReleaseTag, commit: ApprovedCommit) -> Self {
+ Self { tag, commit }
+ }
+
+ /// Returns the approved tag.
+ pub(crate) fn tag(&self) -> &ReleaseTag {
+ &self.tag
+ }
+
+ /// Returns the approved source commit.
+ pub(crate) fn commit(&self) -> &ApprovedCommit {
+ &self.commit
+ }
+}
+
+/// One named, sized and digested release file.
+#[derive(Clone, Debug, Deserialize, PartialEq, Eq)]
+#[serde(deny_unknown_fields)]
+pub(crate) struct FileRecord {
+ pub(crate) name: String,
+ pub(crate) size: u64,
+ pub(crate) sha256: String,
+}
+
+/// Declared runtime compatibility of an artifact.
+#[derive(Clone, Debug, Deserialize, PartialEq, Eq)]
+#[serde(deny_unknown_fields)]
+pub(crate) struct Compatibility {
+ pub(crate) runtime: String,
+ pub(crate) minimum_version: String,
+ pub(crate) architecture: String,
+}
+
+/// The six per-executable companion files.
+#[derive(Clone, Debug, Deserialize, PartialEq, Eq)]
+#[serde(deny_unknown_fields)]
+pub(crate) struct Companions {
+ pub(crate) sbom: FileRecord,
+ pub(crate) provenance: FileRecord,
+ pub(crate) sbom_attestation: FileRecord,
+ pub(crate) build_evidence: FileRecord,
+ pub(crate) build_recipe: FileRecord,
+ pub(crate) rebuild_check: FileRecord,
+}
+
+impl Companions {
+ fn records(&self) -> [(&'static str, u64, &FileRecord); 6] {
+ [
+ (".spdx.json", bounds::SPDX_BYTES, &self.sbom),
+ (
+ ".provenance.sigstore.json",
+ bounds::BUNDLE_BYTES,
+ &self.provenance,
+ ),
+ (
+ ".sbom.sigstore.json",
+ bounds::BUNDLE_BYTES,
+ &self.sbom_attestation,
+ ),
+ (
+ ".build-evidence.json",
+ bounds::COMPANION_BYTES,
+ &self.build_evidence,
+ ),
+ (
+ ".build-recipe.json",
+ bounds::COMPANION_BYTES,
+ &self.build_recipe,
+ ),
+ (
+ ".rebuild-check.json",
+ bounds::COMPANION_BYTES,
+ &self.rebuild_check,
+ ),
+ ]
+ }
+}
+
+/// One executable entry of a release index.
+#[derive(Clone, Debug, Deserialize, PartialEq, Eq)]
+#[serde(deny_unknown_fields)]
+pub(crate) struct IndexArtifact {
+ pub(crate) name: String,
+ pub(crate) size: u64,
+ pub(crate) sha256: String,
+ pub(crate) role: String,
+ pub(crate) kind: String,
+ pub(crate) package: String,
+ pub(crate) target: String,
+ pub(crate) variant: String,
+ pub(crate) assurance_profile: String,
+ pub(crate) capabilities: Vec,
+ pub(crate) compatibility: Compatibility,
+ pub(crate) js_inventory_sha256: String,
+ pub(crate) companions: Companions,
+}
+
+/// A strictly parsed, policy-checked version-1 release index.
+///
+/// Its `policy_version`/`policy_sha256` identify the producer policy only;
+/// [`super::policy::accept_producer_policy`] must still bind them to
+/// approved-source bytes.
+#[derive(Clone, Debug, Deserialize, PartialEq, Eq)]
+#[serde(deny_unknown_fields)]
+pub(crate) struct ReleaseIndex {
+ pub(crate) schema_version: u64,
+ pub(crate) repository: String,
+ pub(crate) tag: String,
+ pub(crate) source_commit: String,
+ pub(crate) recipe_source_commit: String,
+ pub(crate) cohort_source_commit: String,
+ pub(crate) cohort: String,
+ pub(crate) policy_version: u64,
+ pub(crate) policy_sha256: String,
+ pub(crate) tsjs_inventory: FileRecord,
+ pub(crate) artifacts: Vec,
+}
+
+/// Exact bytes of the governing release-index v1 schema.
+const INDEX_SCHEMA: &[u8] =
+ include_bytes!("../../../../docs/superpowers/specs/schemas/release-index-v1.schema.json");
+
+/// Evaluates `value` against `schema` with explicit Draft 2020-12, offline.
+///
+/// Offline evaluation rejects every unregistered reference, so no remote or
+/// file retrieval can occur.
+///
+/// # Errors
+///
+/// Returns [`ReleaseError::Document`] if the schema does not compile.
+pub(crate) fn schema_accepts(schema: &Value, value: &Value) -> Result> {
+ let validator = jsonschema::options()
+ .with_draft(Draft::Draft202012)
+ .offline()
+ .build(schema)
+ .map_err(|source| report(ReleaseError::Document, source))?;
+ Ok(validator.is_valid(value))
+}
+
+/// Validates a strictly parsed index against the retained governing schema.
+///
+/// # Errors
+///
+/// Returns [`ReleaseError::Document`] when the schema rejects the document.
+pub(crate) fn validate_index_schema(value: &Value) -> Result<(), Report> {
+ let schema = parse_json(INDEX_SCHEMA, Role::Index)?;
+ if !schema_accepts(&schema, value)? {
+ return Err(report(
+ ReleaseError::Document,
+ "release index does not match the governing release-index v1 schema",
+ ));
+ }
+ Ok(())
+}
+
+/// Parses and validates release-index bytes against the consumer policy.
+///
+/// Semantic checks run first so diagnostics name the precise contract rule;
+/// the governing schema is then evaluated as an independent structural gate.
+///
+/// # Errors
+///
+/// Returns [`ReleaseError::Document`] for malformed, ambiguous or open-field
+/// JSON and [`ReleaseError::Policy`] for any semantic contract violation.
+pub(crate) fn parse_release_index(
+ bytes: &[u8],
+ policy: &ConsumerPolicy,
+ approved: &ApprovedRelease,
+) -> Result> {
+ let value = parse_json(bytes, Role::Index)?;
+ validate_safe_integers(&value)?;
+ let index = ReleaseIndex::deserialize(&value)
+ .map_err(|source| report(ReleaseError::Document, source))?;
+ check_index_header(&index, policy, approved)?;
+ let mut names = BTreeSet::new();
+ if index.tsjs_inventory.name != INVENTORY_NAME {
+ return Err(report(
+ ReleaseError::Policy,
+ "JS inventory must be named tsjs-inventory.json",
+ ));
+ }
+ check_file(
+ &index.tsjs_inventory,
+ 0,
+ bounds::INVENTORY_BYTES,
+ &mut names,
+ )?;
+ if index.artifacts.len() != policy.artifacts().len() {
+ return Err(report(
+ ReleaseError::Policy,
+ "release index must list exactly the cohort artifacts",
+ ));
+ }
+ for artifact in &index.artifacts {
+ let fixed = policy.artifact(&artifact.name).ok_or_else(|| {
+ report(
+ ReleaseError::Policy,
+ format!("unknown artifact `{}`", artifact.name),
+ )
+ })?;
+ check_artifact(artifact, fixed, &index.tsjs_inventory, &mut names)?;
+ }
+ validate_index_schema(&value)?;
+ Ok(index)
+}
+
+fn check_index_header(
+ index: &ReleaseIndex,
+ policy: &ConsumerPolicy,
+ approved: &ApprovedRelease,
+) -> Result<(), Report> {
+ if index.schema_version != 1 {
+ return Err(report(
+ ReleaseError::Policy,
+ "unsupported release-index schema version",
+ ));
+ }
+ if index.repository != policy.repository() || index.cohort != policy.cohort() {
+ return Err(report(
+ ReleaseError::Policy,
+ "release index names another repository or cohort",
+ ));
+ }
+ ReleaseTag::parse(&index.tag)?;
+ if index.tag != approved.tag().as_str() {
+ return Err(report(
+ ReleaseError::Policy,
+ "release index tag is not the approved tag",
+ ));
+ }
+ if !lower_hex(&index.source_commit, 40) || index.source_commit != approved.commit().as_str() {
+ return Err(report(
+ ReleaseError::Policy,
+ "release index source commit is not the approved commit",
+ ));
+ }
+ if index.recipe_source_commit != index.source_commit
+ || index.cohort_source_commit != index.source_commit
+ {
+ return Err(report(
+ ReleaseError::Policy,
+ "recipe and cohort source commits must equal the approved source",
+ ));
+ }
+ if index.policy_version < 1 || !lower_hex(&index.policy_sha256, 64) {
+ return Err(report(
+ ReleaseError::Policy,
+ "release index producer policy identity is malformed",
+ ));
+ }
+ Ok(())
+}
+
+fn check_file(
+ record: &FileRecord,
+ minimum: u64,
+ maximum: u64,
+ names: &mut BTreeSet,
+) -> Result<(), Report> {
+ if !safe_name(&record.name) {
+ return Err(report(
+ ReleaseError::Policy,
+ "release file name is not a safe flat name",
+ ));
+ }
+ if record.size < minimum || record.size > maximum {
+ return Err(report(
+ ReleaseError::Policy,
+ format!(
+ "release file `{}` length is outside its role bound",
+ record.name
+ ),
+ ));
+ }
+ if !lower_hex(&record.sha256, 64) {
+ return Err(report(
+ ReleaseError::Policy,
+ format!(
+ "release file `{}` digest must be 64 lowercase hex digits",
+ record.name
+ ),
+ ));
+ }
+ if !names.insert(record.name.to_ascii_lowercase()) {
+ return Err(report(
+ ReleaseError::Policy,
+ format!("release file `{}` is listed more than once", record.name),
+ ));
+ }
+ Ok(())
+}
+
+fn check_artifact(
+ artifact: &IndexArtifact,
+ fixed: &FixedArtifact,
+ inventory: &FileRecord,
+ names: &mut BTreeSet,
+) -> Result<(), Report> {
+ let file = FileRecord {
+ name: artifact.name.clone(),
+ size: artifact.size,
+ sha256: artifact.sha256.clone(),
+ };
+ check_file(&file, 1, bounds::EXECUTABLE_BYTES, names)?;
+ check_capabilities(artifact)?;
+ let declared = DeclaredIdentity {
+ role: &artifact.role,
+ kind: &artifact.kind,
+ package: &artifact.package,
+ target: &artifact.target,
+ variant: &artifact.variant,
+ assurance_profile: &artifact.assurance_profile,
+ capabilities: &artifact.capabilities,
+ compatibility: &artifact.compatibility,
+ };
+ if !fixed.matches(&declared) {
+ return Err(report(
+ ReleaseError::Policy,
+ format!(
+ "artifact `{}` differs from the supported role/kind/package/target/variant/profile/capability combination",
+ artifact.name
+ ),
+ ));
+ }
+ if artifact.js_inventory_sha256 != inventory.sha256 {
+ return Err(report(
+ ReleaseError::Policy,
+ format!(
+ "artifact `{}` names a different JS inventory",
+ artifact.name
+ ),
+ ));
+ }
+ check_companions(artifact, names)
+}
+
+fn check_companions(
+ artifact: &IndexArtifact,
+ names: &mut BTreeSet,
+) -> Result<(), Report> {
+ for (suffix, maximum, record) in artifact.companions.records() {
+ if record.name != format!("{}{suffix}", artifact.name) {
+ return Err(report(
+ ReleaseError::Policy,
+ format!(
+ "artifact `{}` companion must be named `{}{suffix}`",
+ artifact.name, artifact.name
+ ),
+ ));
+ }
+ check_file(record, 0, maximum, names)?;
+ }
+ Ok(())
+}
+
+fn check_capabilities(artifact: &IndexArtifact) -> Result<(), Report> {
+ let unique: BTreeSet<&str> = artifact.capabilities.iter().map(String::as_str).collect();
+ if unique.is_empty() || unique.len() != artifact.capabilities.len() || unique.len() > 2 {
+ return Err(report(
+ ReleaseError::Policy,
+ format!(
+ "artifact `{}` capabilities must be one or two unique values",
+ artifact.name
+ ),
+ ));
+ }
+ check_capability_vocabulary(&artifact.capabilities)
+}
+
+/// Requires every number to be a nonnegative safe integer token.
+///
+/// # Errors
+///
+/// Returns [`ReleaseError::Document`] for negative, fractional or unsafe numbers.
+pub(crate) fn validate_safe_integers(value: &Value) -> Result<(), Report> {
+ match value {
+ Value::Number(number)
+ if number
+ .as_u64()
+ .is_none_or(|integer| integer > SAFE_INTEGER_MAX) =>
+ {
+ Err(report(
+ ReleaseError::Document,
+ "closed release documents require nonnegative safe integer tokens",
+ ))
+ }
+ Value::Object(values) => values.values().try_for_each(validate_safe_integers),
+ Value::Array(values) => values.iter().try_for_each(validate_safe_integers),
+ _ => Ok(()),
+ }
+}
+
+// Serde may otherwise convert overflowing integer tokens to finite floats, so
+// every numeric token outside strings is range-checked lexically first.
+fn validate_numbers(text: &str) -> Result<(), Report> {
+ let bytes = text.as_bytes();
+ let mut cursor = 0;
+ while cursor < bytes.len() {
+ if bytes[cursor] == b'"' {
+ cursor += 1;
+ while cursor < bytes.len() {
+ match bytes[cursor] {
+ b'\\' => cursor += 2,
+ b'"' => {
+ cursor += 1;
+ break;
+ }
+ _ => cursor += 1,
+ }
+ }
+ } else if bytes[cursor] == b'-' || bytes[cursor].is_ascii_digit() {
+ let start = cursor;
+ while cursor < bytes.len()
+ && matches!(
+ bytes[cursor],
+ b'-' | b'+' | b'.' | b'e' | b'E' | b'0'..=b'9'
+ )
+ {
+ cursor += 1;
+ }
+ let token = text.get(start..cursor).unwrap_or_default();
+ let valid = if token.contains(['.', 'e', 'E']) {
+ token.parse::().is_ok_and(f64::is_finite)
+ } else if token.starts_with('-') {
+ token.parse::().is_ok()
+ } else {
+ token.parse::().is_ok()
+ };
+ if !valid {
+ return Err(report(
+ ReleaseError::Document,
+ "JSON number exceeds its finite numeric range",
+ ));
+ }
+ } else {
+ cursor += 1;
+ }
+ }
+ Ok(())
+}
+
+struct StrictSeed {
+ depth: usize,
+}
+
+impl<'de> DeserializeSeed<'de> for StrictSeed {
+ type Value = Value;
+
+ fn deserialize>(self, deserializer: D) -> Result {
+ deserializer.deserialize_any(self)
+ }
+}
+
+impl<'de> Visitor<'de> for StrictSeed {
+ type Value = Value;
+
+ fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
+ formatter.write_str("JSON with unique object keys")
+ }
+
+ fn visit_bool(self, value: bool) -> Result {
+ Ok(Value::Bool(value))
+ }
+
+ fn visit_unit(self) -> Result {
+ Ok(Value::Null)
+ }
+
+ fn visit_str(self, value: &str) -> Result {
+ Ok(Value::String(value.to_owned()))
+ }
+
+ fn visit_i64(self, value: i64) -> Result {
+ Ok(Value::Number(Number::from(value)))
+ }
+
+ fn visit_u64(self, value: u64) -> Result {
+ Ok(Value::Number(Number::from(value)))
+ }
+
+ fn visit_f64(self, value: f64) -> Result {
+ Number::from_f64(value)
+ .map(Value::Number)
+ .ok_or_else(|| E::custom("nonfinite number"))
+ }
+
+ fn visit_seq>(self, mut access: A) -> Result {
+ if self.depth > bounds::JSON_DEPTH {
+ return Err(serde::de::Error::custom("JSON container depth exceeds 64"));
+ }
+ let mut values = Vec::new();
+ while let Some(value) = access.next_element_seed(StrictSeed {
+ depth: self.depth + 1,
+ })? {
+ values.push(value);
+ }
+ Ok(Value::Array(values))
+ }
+
+ fn visit_map>(self, mut access: A) -> Result {
+ if self.depth > bounds::JSON_DEPTH {
+ return Err(serde::de::Error::custom("JSON container depth exceeds 64"));
+ }
+ let mut values = Map::new();
+ while let Some(key) = access.next_key::()? {
+ if values.contains_key(&key) {
+ return Err(serde::de::Error::custom("duplicate object key"));
+ }
+ let value = access.next_value_seed(StrictSeed {
+ depth: self.depth + 1,
+ })?;
+ values.insert(key, value);
+ }
+ Ok(Value::Object(values))
+ }
+}
diff --git a/crates/trusted-server-cli/src/release/environment.rs b/crates/trusted-server-cli/src/release/environment.rs
new file mode 100644
index 0000000000..7e349d69ab
--- /dev/null
+++ b/crates/trusted-server-cli/src/release/environment.rs
@@ -0,0 +1,107 @@
+//! Caller environment snapshots and the explicit GitHub credential.
+
+use std::collections::BTreeMap;
+use std::ffi::{OsStr, OsString};
+use std::fmt;
+
+use error_stack::Report;
+
+use super::error::{ReleaseError, report};
+
+/// Text substituted for the credential in every diagnostic.
+pub(crate) const REDACTED: &str = "[redacted]";
+
+/// A snapshot of the caller's environment, taken once.
+///
+/// The gh runner reads only explicit entries from it and never lets the child
+/// inherit it.
+#[derive(Clone, Debug, Default)]
+pub(crate) struct CallerEnvironment(BTreeMap);
+
+impl CallerEnvironment {
+ /// Snapshots the current process environment.
+ pub(crate) fn from_process() -> Self {
+ Self(std::env::vars_os().collect())
+ }
+
+ /// Builds a snapshot from explicit pairs.
+ #[cfg(test)]
+ pub(crate) fn from_pairs(pairs: I) -> Self
+ where
+ K: Into,
+ V: Into,
+ I: IntoIterator- ,
+ {
+ Self(
+ pairs
+ .into_iter()
+ .map(|(key, value)| (key.into(), value.into()))
+ .collect(),
+ )
+ }
+
+ /// Returns one variable's value.
+ pub(crate) fn get(&self, name: &str) -> Option<&OsStr> {
+ self.0.get(OsStr::new(name)).map(OsString::as_os_str)
+ }
+
+ /// Iterates over variable names.
+ pub(crate) fn names(&self) -> impl Iterator
- {
+ self.0.keys().map(OsString::as_os_str)
+ }
+}
+
+/// An explicit read-only GitHub credential.
+///
+/// Its [`fmt::Debug`] output never includes the secret.
+#[derive(Clone)]
+pub(crate) struct GithubToken(String);
+
+impl fmt::Debug for GithubToken {
+ fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
+ write!(formatter, "GithubToken({REDACTED})")
+ }
+}
+
+impl GithubToken {
+ /// Selects explicit `GH_TOKEN`, else `GITHUB_TOKEN`, from the snapshot.
+ ///
+ /// Stored `gh auth login` credentials are deliberately not used.
+ ///
+ /// # Errors
+ ///
+ /// Returns [`ReleaseError::Environment`] when neither variable holds a
+ /// nonempty, printable, whitespace-free value.
+ pub(crate) fn select(environment: &CallerEnvironment) -> Result> {
+ let selected = ["GH_TOKEN", "GITHUB_TOKEN"]
+ .into_iter()
+ .find_map(|name| environment.get(name).filter(|value| !value.is_empty()))
+ .ok_or_else(|| {
+ report(
+ ReleaseError::Environment,
+ "release verification requires an explicit read-only GH_TOKEN \
+ (or GITHUB_TOKEN); stored `gh auth login` credentials are not used",
+ )
+ })?;
+ let token = selected
+ .to_str()
+ .filter(|token| token.bytes().all(|byte| byte.is_ascii_graphic()))
+ .ok_or_else(|| {
+ report(
+ ReleaseError::Environment,
+ "the GitHub token must be printable ASCII without whitespace",
+ )
+ })?;
+ Ok(Self(token.to_owned()))
+ }
+
+ /// Returns the secret for the single place it is transmitted.
+ pub(crate) fn expose(&self) -> &str {
+ &self.0
+ }
+
+ /// Replaces every occurrence of the secret in `text`.
+ pub(crate) fn redact(&self, text: &str) -> String {
+ text.replace(&self.0, REDACTED)
+ }
+}
diff --git a/crates/trusted-server-cli/src/release/error.rs b/crates/trusted-server-cli/src/release/error.rs
new file mode 100644
index 0000000000..11c72052da
--- /dev/null
+++ b/crates/trusted-server-cli/src/release/error.rs
@@ -0,0 +1,73 @@
+//! Concrete release verification errors.
+
+use std::fmt;
+
+use error_stack::Report;
+
+/// Reports why release verification refused an input, policy or process.
+///
+/// Every variant is fail-closed: no verified value is constructed after any of
+/// them. Diagnostic attachments never contain credentials.
+#[derive(Debug, Clone, Copy, PartialEq, Eq, derive_more::Display)]
+pub(crate) enum ReleaseError {
+ /// A JSON document is malformed, ambiguous, oversized or schema-invalid.
+ #[display("invalid release document")]
+ Document,
+ /// The embedded consumer policy or a release index violates the compiled contract.
+ #[display("release policy rejects the input")]
+ Policy,
+ /// The producer policy identity is not current or reviewed historical policy.
+ #[display("producer release policy is not accepted")]
+ ProducerPolicy,
+ /// The approved-source policy could not be acquired from the fixed endpoint.
+ #[display("approved source policy acquisition failed")]
+ PolicyFetch,
+ /// The caller environment cannot safely run the GitHub CLI.
+ #[display("GitHub CLI environment is rejected")]
+ Environment,
+ /// The resolved GitHub CLI is missing or not the qualified version.
+ #[display("unsupported GitHub CLI")]
+ UnsupportedTool,
+ /// A child process could not be spawned, signalled or reaped.
+ #[display("GitHub CLI process failed")]
+ Process,
+ /// A child or attempt deadline elapsed.
+ #[display("release verification deadline elapsed")]
+ Timeout,
+ /// The caller cancelled the verification attempt.
+ #[display("release verification was cancelled")]
+ Cancelled,
+ /// Child stdout or stderr exceeded its byte bound.
+ #[display("GitHub CLI output exceeds its byte bound")]
+ OutputLimit,
+ /// A descendant left the child process group and kept its pipes open.
+ #[display("a GitHub CLI descendant escaped process-group termination")]
+ DescendantEscaped,
+ /// The child exited unsuccessfully; its output is never trusted.
+ #[display("GitHub CLI exited unsuccessfully")]
+ ExitFailure,
+ /// Successful child output is not exactly one attestation result.
+ #[display("GitHub CLI output is rejected")]
+ Output,
+ /// A verified proof, document or digest join of the evidence graph is
+ /// missing, extra or mismatched.
+ #[display("release evidence graph is inconsistent")]
+ Evidence,
+ /// The official GitHub release, tag or release statement is not the
+ /// immutable publication of the verified evidence.
+ #[display("official release publication check failed")]
+ Publication,
+ /// A retained release asset exceeds its role bound, cannot be retained or
+ /// no longer matches its verified digest.
+ #[display("release asset retention failed")]
+ Asset,
+}
+
+impl core::error::Error for ReleaseError {}
+
+/// Builds a report for `context` with one diagnostic attachment.
+///
+/// Callers must never pass credentials or secret context in `message`.
+pub(super) fn report(context: ReleaseError, message: impl fmt::Display) -> Report {
+ Report::new(context).attach(message.to_string())
+}
diff --git a/crates/trusted-server-cli/src/release/evidence.rs b/crates/trusted-server-cli/src/release/evidence.rs
new file mode 100644
index 0000000000..75cf189b0b
--- /dev/null
+++ b/crates/trusted-server-cli/src/release/evidence.rs
@@ -0,0 +1,778 @@
+//! R-EVIDENCE: the complete verified evidence graph of an official release.
+//!
+//! Every proof is the single result object of one successful pinned
+//! `gh attestation verify --bundle ... --format json` run (see
+//! [`super::gh::GhRunner::verify_attestation`]). Primitive gh success is
+//! never sufficient: every certificate field, statement subject and predicate
+//! edge below is checked again here, and any missing, extra or mismatched
+//! edge fails closed.
+//!
+//! JSON shape of the pinned gh 2.97.0 output, confirmed from primary sources:
+//! - Each array element is `AttestationProcessingResult` with `attestation`
+//! and `verificationResult`:
+//!
+//! and the `--format json` help text in
+//! .
+//! - `verificationResult` is sigstore-go v1.2.2 (pinned in gh's `go.mod`)
+//! `VerificationResult`: `mediaType`, `statement` (protojson in-toto v1
+//! Statement: `_type`, `subject[].name`, `subject[].digest`,
+//! `predicateType`, `predicate`) and `signature.certificate`, a
+//! `certificate.Summary` with Fulcio `Extensions` flattened into it:
+//! ,
+//! ,
+//! ,
+//! .
+//! - The GitHub workflow provenance predicate layout follows the
+//! `@actions/attest` builder:
+//! .
+//! Real attest 4.2.2 provenance/SPDX fixtures were verified with gh 2.97.0;
+//! the measured consumer qualification report records their identities,
+//! digests and replay commands. Official project first-use remains separate.
+
+mod joins;
+
+#[cfg(test)]
+mod candidate_fixture_tests;
+#[cfg(test)]
+mod upstream_format_tests;
+
+use std::collections::{BTreeMap, BTreeSet};
+use std::sync::LazyLock;
+
+use error_stack::Report;
+use regex::Regex;
+use serde_json::{Map, Value, json};
+
+use super::artifact::AssetMeasurement;
+use super::documents::{
+ ApprovedRelease, FileRecord, IndexArtifact, ReleaseIndex, Role, lower_hex, parse_json,
+ parse_release_index,
+};
+use super::error::{ReleaseError, report};
+use super::gh::{PredicateKind, VerificationMode, VerifiedAttestation};
+use super::policy::{
+ AcceptanceRules, AcceptedProducerPolicy, BUILD_TYPE, ConsumerPolicy, GITHUB_HOST, ISSUER,
+ PROVENANCE_PREDICATE, REPOSITORY, REPOSITORY_ID, REPOSITORY_OWNER_ID, SBOM_PREDICATE,
+ accept_producer_policy, certificate_identity,
+};
+use super::source_policy::ApprovedSourcePolicy;
+
+pub(crate) use joins::ComparisonOutcome;
+
+/// sigstore-go `VerificationResultMediaType01`.
+const VERIFICATION_MEDIA_TYPE: &str =
+ "application/vnd.dev.sigstore.verificationresult+json;version=0.1";
+/// in-toto v1 statement type.
+const STATEMENT_TYPE: &str = "https://in-toto.io/Statement/v1";
+/// Fixed name of the signed release index.
+pub(crate) const INDEX_NAME: &str = "release-index.json";
+/// Fixed sentinel tag of every unqualified candidate index the release tool
+/// assembles; it never names an official release.
+const CANDIDATE_TAG: &str = "v0.0.0-unqualified-candidate";
+/// Companion field a candidate producer sets to `false`.
+const RELEASE_QUALIFICATION: &str = "release_qualification";
+/// Caller workflow path inside the official repository.
+const CALLER_PATH: &str = ".github/workflows/release.yml";
+const CERTIFICATE: [&str; 3] = ["verificationResult", "signature", "certificate"];
+const STATEMENT: [&str; 2] = ["verificationResult", "statement"];
+
+static RUN_URI: LazyLock = LazyLock::new(|| {
+ Regex::new(
+ r"^https://github\.com/IABTechLab/trusted-server/actions/runs/[1-9][0-9]{0,19}/attempts/[1-9][0-9]{0,4}$", // allow-domain: github
+ )
+ .expect("should compile the reviewed run invocation pattern")
+});
+
+/// Identity values derived only from the independently approved release.
+struct Expected {
+ san: String,
+ caller: String,
+ source_ref: String,
+ commit: String,
+ repository_uri: String,
+}
+
+impl Expected {
+ fn new(release: &ApprovedRelease) -> Self {
+ let repository_uri = format!("https://{GITHUB_HOST}/{REPOSITORY}");
+ let source_ref = format!("refs/tags/{}", release.tag().as_str());
+ Self {
+ san: certificate_identity(release.tag()),
+ caller: format!("{repository_uri}/{CALLER_PATH}@{source_ref}"),
+ source_ref,
+ commit: release.commit().as_str().to_owned(),
+ repository_uri,
+ }
+ }
+}
+
+pub(super) fn at<'a>(value: &'a Value, path: &[&str]) -> Option<&'a Value> {
+ path.iter().try_fold(value, |current, key| current.get(key))
+}
+
+pub(super) fn evidence_error(message: impl std::fmt::Display) -> Report {
+ report(ReleaseError::Evidence, message)
+}
+
+fn require_text<'a>(value: &'a Value, path: &[&str]) -> Result<&'a str, Report> {
+ at(value, path)
+ .and_then(Value::as_str)
+ .ok_or_else(|| evidence_error(format!("proof field `{}` is missing", path.join("."))))
+}
+
+/// Checks the verified certificate of one proof and returns its run URI.
+fn check_certificate(proof: &Value, expected: &Expected) -> Result> {
+ let certificate = at(proof, &CERTIFICATE)
+ .ok_or_else(|| evidence_error("proof has no verified certificate"))?;
+ let fields = [
+ ("subjectAlternativeName", expected.san.as_str()),
+ ("issuer", ISSUER),
+ ("buildSignerURI", expected.san.as_str()),
+ ("buildSignerDigest", expected.commit.as_str()),
+ ("sourceRepositoryURI", expected.repository_uri.as_str()),
+ ("sourceRepositoryDigest", expected.commit.as_str()),
+ ("sourceRepositoryRef", expected.source_ref.as_str()),
+ ("sourceRepositoryIdentifier", REPOSITORY_ID),
+ ("sourceRepositoryOwnerIdentifier", REPOSITORY_OWNER_ID),
+ ("buildConfigURI", expected.caller.as_str()),
+ ("buildConfigDigest", expected.commit.as_str()),
+ ("buildTrigger", "push"),
+ ("runnerEnvironment", "github-hosted"),
+ ];
+ for (field, value) in fields {
+ if require_text(certificate, &[field])? != value {
+ return Err(evidence_error(format!(
+ "certificate `{field}` differs from the approved release identity"
+ )));
+ }
+ }
+ let run = require_text(certificate, &["runInvocationURI"])?;
+ if !RUN_URI.is_match(run) {
+ return Err(evidence_error(
+ "certificate `runInvocationURI` is not an official repository run attempt",
+ ));
+ }
+ Ok(run.to_owned())
+}
+
+/// Returns the verified statement after checking its envelope types.
+fn statement<'a>(
+ proof: &'a Value,
+ predicate_type: &str,
+) -> Result<&'a Value, Report> {
+ if require_text(proof, &["verificationResult", "mediaType"])? != VERIFICATION_MEDIA_TYPE {
+ return Err(evidence_error("unsupported verification result media type"));
+ }
+ let statement =
+ at(proof, &STATEMENT).ok_or_else(|| evidence_error("proof has no verified statement"))?;
+ if require_text(statement, &["_type"])? != STATEMENT_TYPE {
+ return Err(evidence_error("statement is not an in-toto v1 statement"));
+ }
+ if require_text(statement, &["predicateType"])? != predicate_type {
+ return Err(evidence_error(format!(
+ "statement predicate type is not `{predicate_type}`"
+ )));
+ }
+ Ok(statement)
+}
+
+fn subject_entry(subject: &Value) -> Option<(&str, &str)> {
+ let object = subject.as_object()?;
+ let digest = object.get("digest")?.as_object()?;
+ let name = object.get("name")?.as_str()?;
+ let sha256 = digest.get("sha256")?.as_str()?;
+ (object.len() == 2 && digest.len() == 1 && lower_hex(sha256, 64)).then_some((name, sha256))
+}
+
+/// Requires the statement subjects to be exactly `expected` (name → SHA-256).
+fn check_subjects(
+ statement: &Value,
+ expected: &BTreeMap<&str, &str>,
+) -> Result<(), Report> {
+ let subjects = at(statement, &["subject"])
+ .and_then(Value::as_array)
+ .ok_or_else(|| evidence_error("statement has no subject array"))?;
+ let mut signed = BTreeMap::new();
+ for subject in subjects {
+ let (name, sha256) = subject_entry(subject)
+ .ok_or_else(|| evidence_error("statement subject is not exactly {name, sha256}"))?;
+ if signed.insert(name, sha256).is_some() {
+ return Err(evidence_error(format!(
+ "statement names subject `{name}` twice"
+ )));
+ }
+ }
+ if &signed != expected {
+ return Err(evidence_error(
+ "signed subjects differ from the exact expected names and digests",
+ ));
+ }
+ Ok(())
+}
+
+fn expected_provenance(expected: &Expected, run: &str) -> Value {
+ json!({
+ "buildDefinition": {
+ "buildType": BUILD_TYPE,
+ "externalParameters": {
+ "workflow": {
+ "ref": expected.source_ref,
+ "repository": expected.repository_uri,
+ "path": CALLER_PATH,
+ }
+ },
+ "internalParameters": {
+ "github": {
+ "event_name": "push",
+ "repository_id": REPOSITORY_ID,
+ "repository_owner_id": REPOSITORY_OWNER_ID,
+ "runner_environment": "github-hosted",
+ }
+ },
+ "resolvedDependencies": [{
+ "uri": format!("git+{}@{}", expected.repository_uri, expected.source_ref),
+ "digest": {"gitCommit": expected.commit},
+ }],
+ },
+ "runDetails": {
+ "builder": {"id": expected.san},
+ "metadata": {"invocationId": run},
+ },
+ })
+}
+
+/// Checks the SLSA predicate section by section, then exactly.
+fn check_provenance_predicate(
+ statement: &Value,
+ expected: &Expected,
+ run: &str,
+) -> Result<(), Report> {
+ let wanted = expected_provenance(expected, run);
+ let actual = at(statement, &["predicate"])
+ .ok_or_else(|| evidence_error("provenance statement has no predicate"))?;
+ let sections: [&[&str]; 6] = [
+ &["buildDefinition", "buildType"],
+ &["buildDefinition", "externalParameters"],
+ &["buildDefinition", "internalParameters"],
+ &["buildDefinition", "resolvedDependencies"],
+ &["runDetails", "builder"],
+ &["runDetails", "metadata"],
+ ];
+ for section in sections {
+ if at(actual, section) != at(&wanted, section) {
+ return Err(evidence_error(format!(
+ "provenance `{}` differs from the approved caller, source or run",
+ section.join(".")
+ )));
+ }
+ }
+ if actual != &wanted {
+ return Err(evidence_error(
+ "provenance predicate contains unexpected structures",
+ ));
+ }
+ Ok(())
+}
+
+/// Verifies one provenance proof and returns its certificate run URI.
+fn check_provenance_proof(
+ proof: &Value,
+ expected: &Expected,
+ subjects: &BTreeMap<&str, &str>,
+) -> Result> {
+ let run = check_certificate(proof, expected)?;
+ let statement = statement(proof, PROVENANCE_PREDICATE)?;
+ check_provenance_predicate(statement, expected, &run)?;
+ check_subjects(statement, subjects)?;
+ Ok(run)
+}
+
+fn require_same_run(run: &str, other: &str) -> Result<(), Report> {
+ if run != other {
+ return Err(evidence_error(
+ "collected proofs come from different workflow runs or attempts",
+ ));
+ }
+ Ok(())
+}
+
+fn check_measurement(
+ record: &FileRecord,
+ actual: &AssetMeasurement,
+) -> Result<(), Report> {
+ if record.size != actual.length() || record.sha256 != actual.sha256() {
+ return Err(evidence_error(format!(
+ "`{}` bytes differ from the signed index length or digest",
+ record.name
+ )));
+ }
+ Ok(())
+}
+
+/// Retained bytes and verified proofs for one consumed cohort artifact.
+#[derive(Clone, Copy, Debug)]
+pub(crate) struct ConsumedArtifact<'a> {
+ pub(crate) name: &'a str,
+ pub(crate) executable: &'a AssetMeasurement,
+ pub(crate) spdx: &'a [u8],
+ pub(crate) build_evidence: &'a [u8],
+ pub(crate) build_recipe: &'a [u8],
+ pub(crate) rebuild_check: &'a [u8],
+ pub(crate) provenance_bundle: &'a AssetMeasurement,
+ pub(crate) sbom_bundle: &'a AssetMeasurement,
+ pub(crate) provenance_proof: &'a VerifiedAttestation,
+ pub(crate) spdx_proof: &'a VerifiedAttestation,
+}
+
+/// Every independently obtained trust input and downloaded evidence item.
+#[derive(Clone, Copy, Debug)]
+pub(crate) struct EvidenceInputs<'a> {
+ pub(crate) release: &'a ApprovedRelease,
+ pub(crate) consumer_policy: &'a ConsumerPolicy,
+ pub(crate) source_policy: &'a ApprovedSourcePolicy,
+ pub(crate) index_bytes: &'a [u8],
+ pub(crate) index_proof: &'a VerifiedAttestation,
+ pub(crate) index_bundle: &'a AssetMeasurement,
+ pub(crate) inventory_bytes: &'a [u8],
+ pub(crate) artifacts: &'a [ConsumedArtifact<'a>],
+}
+
+/// The verified evidence of one consumed artifact.
+#[derive(Clone, Debug, PartialEq, Eq)]
+pub(crate) struct VerifiedArtifactEvidence {
+ name: String,
+ measurement: AssetMeasurement,
+ assurance_profile: String,
+ comparison: ComparisonOutcome,
+}
+
+impl VerifiedArtifactEvidence {
+ /// Returns the asset name.
+ pub(crate) fn name(&self) -> &str {
+ &self.name
+ }
+
+ /// Returns the verified executable digest and length.
+ pub(crate) fn measurement(&self) -> &AssetMeasurement {
+ &self.measurement
+ }
+
+ /// Returns the required assurance profile of this artifact.
+ pub(crate) fn assurance_profile(&self) -> &str {
+ &self.assurance_profile
+ }
+
+ /// Returns the informational rebuild comparison outcome.
+ pub(crate) fn comparison(&self) -> ComparisonOutcome {
+ self.comparison
+ }
+}
+
+/// Every R-EVIDENCE edge held for the consumed artifacts.
+///
+/// Only [`verify_build_evidence`] constructs it. It proves build evidence,
+/// not official publication; see [`super::github::verify_publication`].
+#[derive(Debug)]
+pub(crate) struct BuildEvidenceVerified {
+ mode: VerificationMode,
+ release: ApprovedRelease,
+ index: ReleaseIndex,
+ index_measurement: AssetMeasurement,
+ index_bundle: AssetMeasurement,
+ consumer_policy: ConsumerPolicy,
+ producer_policy: AcceptedProducerPolicy,
+ run_invocation: String,
+ consumed: Vec,
+}
+
+impl BuildEvidenceVerified {
+ /// Returns the trust context shared by every build proof.
+ pub(crate) fn mode(&self) -> VerificationMode {
+ self.mode
+ }
+ /// Returns the approved release.
+ pub(crate) fn release(&self) -> &ApprovedRelease {
+ &self.release
+ }
+
+ /// Returns the provenance-verified index.
+ pub(crate) fn index(&self) -> &ReleaseIndex {
+ &self.index
+ }
+
+ /// Returns the exact index bytes' digest and length.
+ pub(crate) fn index_measurement(&self) -> &AssetMeasurement {
+ &self.index_measurement
+ }
+
+ /// Returns the exact provenance bundle verified for this index.
+ pub(crate) fn index_bundle(&self) -> &AssetMeasurement {
+ &self.index_bundle
+ }
+
+ /// Returns the consumer policy version.
+ pub(crate) fn consumer_policy_version(&self) -> u64 {
+ self.consumer_policy.version()
+ }
+
+ /// Returns the consumer policy exact-byte digest.
+ pub(crate) fn consumer_policy_sha256(&self) -> &str {
+ self.consumer_policy.sha256()
+ }
+
+ /// Returns how the producer policy was accepted.
+ pub(crate) fn producer_policy(&self) -> &AcceptedProducerPolicy {
+ &self.producer_policy
+ }
+
+ /// Returns the single workflow run attempt shared by every proof.
+ pub(crate) fn run_invocation(&self) -> &str {
+ &self.run_invocation
+ }
+
+ /// Returns the verified consumed artifacts.
+ pub(crate) fn consumed(&self) -> &[VerifiedArtifactEvidence] {
+ &self.consumed
+ }
+}
+
+/// Verifies index provenance and approved-source policy before using its fields.
+///
+/// # Errors
+///
+/// Returns evidence, document or policy errors for any invalid proof or binding.
+pub(crate) fn verify_index(
+ release: &ApprovedRelease,
+ policy: &ConsumerPolicy,
+ source: &ApprovedSourcePolicy,
+ bytes: &[u8],
+ proof: &VerifiedAttestation,
+ bundle: &AssetMeasurement,
+) -> Result> {
+ check_not_candidate_tag(release)?;
+ let measurement = AssetMeasurement::of_bytes(bytes);
+ let subjects = BTreeMap::from([(INDEX_NAME, measurement.sha256())]);
+ let claims = proof.for_inputs(release, PredicateKind::Provenance, &measurement, bundle)?;
+ check_provenance_proof(claims, &Expected::new(release), &subjects)?;
+ let index = parse_release_index(bytes, policy, release)?;
+ index.artifacts.iter().try_for_each(check_bundle_records)?;
+ accept_producer_policy(&index, source, &AcceptanceRules::compiled(policy))?;
+ Ok(index)
+}
+
+/// Verifies the complete evidence graph for the consumed artifacts.
+///
+/// Index provenance is verified against the approved release before any
+/// index field is trusted; the index is then parsed under the embedded
+/// policy and its producer-policy identity bound to approved-source bytes.
+///
+/// # Errors
+///
+/// Returns [`ReleaseError::Evidence`] for any missing, extra or mismatched
+/// edge, and document or policy errors from index parsing and producer-policy
+/// acceptance.
+pub(crate) fn verify_build_evidence(
+ inputs: &EvidenceInputs<'_>,
+) -> Result> {
+ check_not_candidate_tag(inputs.release)?;
+ check_consumed_names(inputs.artifacts)?;
+ let mode = inputs.index_proof.mode();
+ if inputs.artifacts.iter().any(|artifact| {
+ artifact.provenance_proof.mode() != mode || artifact.spdx_proof.mode() != mode
+ }) {
+ return Err(evidence_error(
+ "build proofs have different online/offline trust contexts",
+ ));
+ }
+
+ let expected = Expected::new(inputs.release);
+ let index_measurement = AssetMeasurement::of_bytes(inputs.index_bytes);
+ let index_subject = BTreeMap::from([(INDEX_NAME, index_measurement.sha256())]);
+ let index_proof = inputs.index_proof.for_inputs(
+ inputs.release,
+ PredicateKind::Provenance,
+ &index_measurement,
+ inputs.index_bundle,
+ )?;
+ let run = check_provenance_proof(index_proof, &expected, &index_subject)?;
+ let index = parse_release_index(inputs.index_bytes, inputs.consumer_policy, inputs.release)?;
+ // Every indexed artifact, consumed or not, must name non-empty bundles:
+ // an unsigned candidate index is rejected as a whole.
+ index.artifacts.iter().try_for_each(check_bundle_records)?;
+ let producer_policy = accept_producer_policy(
+ &index,
+ inputs.source_policy,
+ &AcceptanceRules::compiled(inputs.consumer_policy),
+ )?;
+ check_measurement(
+ &index.tsjs_inventory,
+ &AssetMeasurement::of_bytes(inputs.inventory_bytes),
+ )?;
+ let modules = joins::parse_inventory(inputs.inventory_bytes, &expected.commit)?;
+ let context = ArtifactContext {
+ index: &index,
+ expected: &expected,
+ run: &run,
+ modules: &modules,
+ release: inputs.release,
+ };
+ let consumed = inputs
+ .artifacts
+ .iter()
+ .map(|artifact| context.verify(artifact))
+ .collect::, _>>()?;
+ Ok(BuildEvidenceVerified {
+ mode,
+ release: inputs.release.clone(),
+ index,
+ index_measurement,
+ index_bundle: inputs.index_bundle.clone(),
+ consumer_policy: inputs.consumer_policy.clone(),
+ producer_policy,
+ run_invocation: run,
+ consumed,
+ })
+}
+
+fn check_consumed_names(artifacts: &[ConsumedArtifact<'_>]) -> Result<(), Report> {
+ let names: BTreeSet<&str> = artifacts.iter().map(|artifact| artifact.name).collect();
+ if artifacts.is_empty() || names.len() != artifacts.len() {
+ return Err(evidence_error(
+ "verification needs one or more distinct consumed artifacts",
+ ));
+ }
+ Ok(())
+}
+
+struct ArtifactContext<'a> {
+ index: &'a ReleaseIndex,
+ expected: &'a Expected,
+ run: &'a str,
+ modules: &'a [joins::ModuleRecord],
+ release: &'a ApprovedRelease,
+}
+
+impl ArtifactContext<'_> {
+ fn verify(
+ &self,
+ consumed: &ConsumedArtifact<'_>,
+ ) -> Result> {
+ let entry = self
+ .index
+ .artifacts
+ .iter()
+ .find(|entry| entry.name == consumed.name)
+ .ok_or_else(|| {
+ evidence_error(format!(
+ "`{}` is not a signed index artifact",
+ consumed.name
+ ))
+ })?;
+ check_bundle_records(entry)?;
+ check_companion_bytes(entry, consumed)?;
+ check_release_qualification(entry, consumed)?;
+ joins::parse_informational(consumed.build_recipe)?;
+ let provenance_subjects = provenance_subjects(entry);
+ let proof = consumed.provenance_proof.for_inputs(
+ self.release,
+ PredicateKind::Provenance,
+ consumed.executable,
+ consumed.provenance_bundle,
+ )?;
+ let run = check_provenance_proof(proof, self.expected, &provenance_subjects)?;
+ require_same_run(self.run, &run)?;
+ self.check_sbom_proof(entry, consumed)?;
+ joins::check_build_evidence(consumed.build_evidence, entry, self.index, self.modules)?;
+ Ok(VerifiedArtifactEvidence {
+ name: entry.name.clone(),
+ measurement: consumed.executable.clone(),
+ assurance_profile: entry.assurance_profile.clone(),
+ comparison: joins::parse_comparison(consumed.rebuild_check)?,
+ })
+ }
+
+ fn check_sbom_proof(
+ &self,
+ entry: &IndexArtifact,
+ consumed: &ConsumedArtifact<'_>,
+ ) -> Result<(), Report> {
+ let proof = consumed.spdx_proof.for_inputs(
+ self.release,
+ PredicateKind::Spdx,
+ consumed.executable,
+ consumed.sbom_bundle,
+ )?;
+ let run = check_certificate(proof, self.expected)?;
+ require_same_run(self.run, &run)?;
+ let statement = statement(proof, SBOM_PREDICATE)?;
+ check_subjects(
+ statement,
+ &BTreeMap::from([(entry.name.as_str(), entry.sha256.as_str())]),
+ )?;
+ let predicate = at(statement, &["predicate"])
+ .ok_or_else(|| evidence_error("SPDX statement has no predicate"))?;
+ joins::check_spdx(predicate, consumed.spdx, &entry.sha256)
+ }
+}
+
+/// Requires both signature bundle records to name non-empty files.
+///
+/// An unsigned candidate index records each awaiting-signature bundle as the
+/// true empty-file record (size 0). Provenance and SBOM sigstore bundles must
+/// exist, so a size-0 record fails closed before any byte or proof
+/// comparison. Verification applies this to every indexed artifact right
+/// after parsing the index, and again to each consumed artifact.
+///
+/// # Errors
+///
+/// Returns [`ReleaseError::Evidence`] when either bundle record has size 0.
+fn check_bundle_records(entry: &IndexArtifact) -> Result<(), Report> {
+ let companions = &entry.companions;
+ for record in [&companions.provenance, &companions.sbom_attestation] {
+ if record.size == 0 {
+ return Err(evidence_error(format!(
+ "signature bundle `{}` is empty; an unsigned candidate index cannot be verified",
+ record.name
+ )));
+ }
+ }
+ Ok(())
+}
+
+/// Rejects the candidate sentinel tag before any proof or index field is used.
+///
+/// Full verification requires an index tag equal to the approved tag, so
+/// checking the approved release rejects every candidate index. The match is
+/// exact: the release-index v1 schema reserves no `v0.0.0-` prefix, and
+/// other `v0.0.0` pre-release tags remain valid semantic-version tags.
+///
+/// # Errors
+///
+/// Returns [`ReleaseError::Policy`] when the approved tag is the sentinel.
+fn check_not_candidate_tag(release: &ApprovedRelease) -> Result<(), Report> {
+ if release.tag().as_str() == CANDIDATE_TAG {
+ return Err(report(
+ ReleaseError::Policy,
+ format!(
+ "`{CANDIDATE_TAG}` is the unqualified candidate sentinel tag, not an official release"
+ ),
+ ));
+ }
+ Ok(())
+}
+
+/// Rejects consumed companions that declare themselves unqualified.
+///
+/// Build evidence, build recipe and rebuild check of a candidate carry
+/// `"release_qualification": false`. The R-INDEX field contracts do not
+/// define the field and no closed companion schema is retained, so an absent
+/// field is accepted and `true` is accepted; `false` and any non-boolean
+/// value fail closed. Runs after [`check_companion_bytes`] so only the
+/// index-authenticated bytes are read, and independently of
+/// [`check_bundle_records`].
+///
+/// # Errors
+///
+/// Returns [`ReleaseError::Document`] for malformed JSON and
+/// [`ReleaseError::Evidence`] for a `false` or non-boolean declaration.
+fn check_release_qualification(
+ entry: &IndexArtifact,
+ consumed: &ConsumedArtifact<'_>,
+) -> Result<(), Report> {
+ let companions = &entry.companions;
+ let documents = [
+ (&companions.build_evidence, consumed.build_evidence),
+ (&companions.build_recipe, consumed.build_recipe),
+ (&companions.rebuild_check, consumed.rebuild_check),
+ ];
+ for (record, bytes) in documents {
+ let document = parse_json(bytes, Role::Companion)?;
+ match document.get(RELEASE_QUALIFICATION) {
+ None | Some(Value::Bool(true)) => {}
+ Some(Value::Bool(false)) => {
+ return Err(evidence_error(format!(
+ "`{}` declares `{RELEASE_QUALIFICATION}: false`; an unqualified candidate cannot be verified",
+ record.name
+ )));
+ }
+ Some(_) => {
+ return Err(evidence_error(format!(
+ "`{}` field `{RELEASE_QUALIFICATION}` must be a boolean",
+ record.name
+ )));
+ }
+ }
+ }
+ Ok(())
+}
+
+/// Index → executable and companions: exact actual lengths and digests.
+fn check_companion_bytes(
+ entry: &IndexArtifact,
+ consumed: &ConsumedArtifact<'_>,
+) -> Result<(), Report> {
+ let executable = FileRecord {
+ name: entry.name.clone(),
+ size: entry.size,
+ sha256: entry.sha256.clone(),
+ };
+ let companions = &entry.companions;
+ let pairs = [
+ (&executable, consumed.executable.clone()),
+ (&companions.sbom, AssetMeasurement::of_bytes(consumed.spdx)),
+ (
+ &companions.build_evidence,
+ AssetMeasurement::of_bytes(consumed.build_evidence),
+ ),
+ (
+ &companions.build_recipe,
+ AssetMeasurement::of_bytes(consumed.build_recipe),
+ ),
+ (
+ &companions.rebuild_check,
+ AssetMeasurement::of_bytes(consumed.rebuild_check),
+ ),
+ (&companions.provenance, consumed.provenance_bundle.clone()),
+ (&companions.sbom_attestation, consumed.sbom_bundle.clone()),
+ ];
+ pairs
+ .iter()
+ .try_for_each(|(record, actual)| check_measurement(record, actual))
+}
+
+/// Artifact provenance subjects: executable, SBOM, build evidence, recipe and
+/// rebuild check, exactly.
+fn provenance_subjects(entry: &IndexArtifact) -> BTreeMap<&str, &str> {
+ let companions = &entry.companions;
+ [
+ (entry.name.as_str(), entry.sha256.as_str()),
+ (
+ companions.sbom.name.as_str(),
+ companions.sbom.sha256.as_str(),
+ ),
+ (
+ companions.build_evidence.name.as_str(),
+ companions.build_evidence.sha256.as_str(),
+ ),
+ (
+ companions.build_recipe.name.as_str(),
+ companions.build_recipe.sha256.as_str(),
+ ),
+ (
+ companions.rebuild_check.name.as_str(),
+ companions.rebuild_check.sha256.as_str(),
+ ),
+ ]
+ .into_iter()
+ .collect()
+}
+
+/// Returns the object at `value`, or an evidence error naming `what`.
+pub(super) fn object<'a>(
+ value: &'a Value,
+ what: &str,
+) -> Result<&'a Map, Report> {
+ value
+ .as_object()
+ .ok_or_else(|| evidence_error(format!("{what} must be a JSON object")))
+}
diff --git a/crates/trusted-server-cli/src/release/evidence/candidate_fixture_tests.rs b/crates/trusted-server-cli/src/release/evidence/candidate_fixture_tests.rs
new file mode 100644
index 0000000000..de5d300111
--- /dev/null
+++ b/crates/trusted-server-cli/src/release/evidence/candidate_fixture_tests.rs
@@ -0,0 +1,483 @@
+//! Consumer checks over the producer's unsigned candidate index.
+//!
+//! The committed bytes are produced and golden-tested by the release tool
+//! (`trusted-server-release` `distribution::tests::golden`) and included here
+//! test-only; neither crate depends on the other. Every document join the
+//! consumer runs without signatures must accept them, while the size-0
+//! awaiting-signature bundle records must keep the candidate unverifiable.
+//! Full verification independently rejects the sentinel tag and each
+//! `"release_qualification": false` companion, even with non-empty bundles.
+//!
+//! The ignored ordinary-CI test runs the same checks over the index the
+//! release tool's collector assembled from four real candidate jobs.
+
+use std::collections::BTreeMap;
+use std::io::Read as _;
+use std::path::Path;
+
+use error_stack::Report;
+use serde_json::{Value, json};
+
+use super::{
+ BuildEvidenceVerified, ConsumedArtifact, EvidenceInputs, check_bundle_records,
+ check_release_qualification, joins, verify_build_evidence, verify_index,
+};
+use crate::release::artifact::AssetMeasurement;
+use crate::release::documents::{
+ ApprovedCommit, ApprovedRelease, FileRecord, ReleaseTag, Role, parse_json, parse_release_index,
+ sha256_hex,
+};
+use crate::release::error::ReleaseError;
+use crate::release::evidence::ComparisonOutcome;
+use crate::release::gh::{PredicateKind, VerifiedAttestation};
+use crate::release::policy::{
+ AcceptanceRules, AcceptedProducerPolicy, EMBEDDED_POLICY, accept_producer_policy,
+ embedded_policy,
+};
+use crate::release::source_policy::ApprovedSourcePolicy;
+
+/// The fixed sentinel tag of every candidate index.
+const CANDIDATE_TAG: &str = "v0.0.0-unqualified-candidate";
+/// The fixed name of every candidate index.
+const CANDIDATE_INDEX_NAME: &str = "candidate-release-index.json";
+/// The fixed name of the shared JS inventory.
+const INVENTORY_NAME: &str = "tsjs-inventory.json";
+/// The fictional source commit of the committed candidate fixture.
+const COMMIT: &str = "0123456789abcdef0123456789abcdef01234567";
+
+macro_rules! candidate_fixture {
+ ($name:literal) => {
+ (
+ $name,
+ include_bytes!(concat!(
+ "../../../../trusted-server-release/tests/fixtures/index/candidate/",
+ $name
+ ))
+ .as_slice(),
+ )
+ };
+}
+
+/// Every committed candidate file the consumer reads.
+const FIXTURES: [(&str, &[u8]); 18] = [
+ candidate_fixture!("candidate-release-index.json"),
+ candidate_fixture!("tsjs-inventory.json"),
+ candidate_fixture!("trusted-server-fastly.wasm.spdx.json"),
+ candidate_fixture!("trusted-server-fastly.wasm.build-evidence.json"),
+ candidate_fixture!("trusted-server-fastly.wasm.build-recipe.json"),
+ candidate_fixture!("trusted-server-fastly.wasm.rebuild-check.json"),
+ candidate_fixture!("ts-aarch64-apple-darwin.spdx.json"),
+ candidate_fixture!("ts-aarch64-apple-darwin.build-evidence.json"),
+ candidate_fixture!("ts-aarch64-apple-darwin.build-recipe.json"),
+ candidate_fixture!("ts-aarch64-apple-darwin.rebuild-check.json"),
+ candidate_fixture!("ts-x86_64-apple-darwin.spdx.json"),
+ candidate_fixture!("ts-x86_64-apple-darwin.build-evidence.json"),
+ candidate_fixture!("ts-x86_64-apple-darwin.build-recipe.json"),
+ candidate_fixture!("ts-x86_64-apple-darwin.rebuild-check.json"),
+ candidate_fixture!("ts-x86_64-unknown-linux-gnu.spdx.json"),
+ candidate_fixture!("ts-x86_64-unknown-linux-gnu.build-evidence.json"),
+ candidate_fixture!("ts-x86_64-unknown-linux-gnu.build-recipe.json"),
+ candidate_fixture!("ts-x86_64-unknown-linux-gnu.rebuild-check.json"),
+];
+
+/// The byte-limit role the consumer applies to the candidate file `name`.
+fn role_for(name: &str) -> Role {
+ if name == CANDIDATE_INDEX_NAME {
+ Role::Index
+ } else if name == INVENTORY_NAME {
+ Role::Inventory
+ } else if name.ends_with(".spdx.json") {
+ Role::Spdx
+ } else {
+ Role::Companion
+ }
+}
+
+/// Reads one regular file of at most `limit` bytes.
+fn read_bounded(path: &Path, limit: u64) -> Vec {
+ let metadata = std::fs::symlink_metadata(path).expect("should inspect the candidate file");
+ assert!(
+ metadata.is_file() && metadata.len() <= limit,
+ "should read a regular candidate file within its bound: {}",
+ path.display()
+ );
+ let mut bytes = Vec::new();
+ std::fs::File::open(path)
+ .expect("should open the candidate file")
+ .take(limit + 1)
+ .read_to_end(&mut bytes)
+ .expect("should read the candidate file");
+ assert!(
+ bytes.len() as u64 <= limit,
+ "should stay within the role bound: {}",
+ path.display()
+ );
+ bytes
+}
+
+/// The eighteen candidate files a consumer reads and their source commit.
+struct CandidateFiles {
+ commit: String,
+ files: BTreeMap<&'static str, Vec>,
+}
+
+impl CandidateFiles {
+ /// The committed fictional golden fixture.
+ fn committed() -> Self {
+ Self {
+ commit: COMMIT.to_owned(),
+ files: FIXTURES
+ .iter()
+ .map(|(name, bytes)| (*name, bytes.to_vec()))
+ .collect(),
+ }
+ }
+
+ /// Exactly the eighteen named files of an assembled index directory.
+ fn from_directory(root: &Path, commit: String) -> Self {
+ let present = std::fs::read_dir(root)
+ .expect("should list the assembled candidate index directory")
+ .count();
+ assert_eq!(
+ present,
+ FIXTURES.len(),
+ "should hold exactly the index, inventory and sixteen companions"
+ );
+ let files = FIXTURES
+ .iter()
+ .map(|(name, _)| {
+ (
+ *name,
+ read_bounded(&root.join(name), role_for(name).limit()),
+ )
+ })
+ .collect();
+ Self { commit, files }
+ }
+
+ fn bytes(&self, name: &str) -> &[u8] {
+ self.files
+ .get(name)
+ .map(Vec::as_slice)
+ .expect("should hold the named candidate file")
+ }
+
+ fn approved_commit(&self) -> ApprovedCommit {
+ ApprovedCommit::parse(&self.commit).expect("should accept the candidate commit")
+ }
+
+ fn approved(&self) -> ApprovedRelease {
+ ApprovedRelease::new(
+ ReleaseTag::parse(CANDIDATE_TAG).expect("should accept the sentinel tag pattern"),
+ self.approved_commit(),
+ )
+ }
+
+ fn present_bytes(&self, record: &FileRecord) -> &[u8] {
+ let bytes = self.bytes(&record.name);
+ assert_eq!(
+ (bytes.len() as u64, sha256_hex(bytes)),
+ (record.size, record.sha256.clone()),
+ "should match the indexed length and digest of {}",
+ record.name
+ );
+ bytes
+ }
+}
+
+/// Runs every unsigned consumer join over `candidate`.
+fn check_unsigned_consumer_joins(candidate: &CandidateFiles) {
+ let policy = embedded_policy().expect("should accept the embedded consumer policy");
+ let index = parse_release_index(
+ candidate.bytes(CANDIDATE_INDEX_NAME),
+ &policy,
+ &candidate.approved(),
+ )
+ .expect("should parse the candidate index under the consumer policy");
+ let source =
+ ApprovedSourcePolicy::for_tests(candidate.approved_commit(), EMBEDDED_POLICY.to_vec());
+ assert_eq!(
+ accept_producer_policy(&index, &source, &AcceptanceRules::compiled(&policy))
+ .expect("should accept the producer policy"),
+ AcceptedProducerPolicy::Current,
+ "should bind the producer policy to the current consumer policy"
+ );
+ let inventory = candidate.present_bytes(&index.tsjs_inventory);
+ let modules = joins::parse_inventory(inventory, &candidate.commit)
+ .expect("should parse the candidate inventory");
+ for entry in &index.artifacts {
+ let companions = &entry.companions;
+ let evidence = candidate.present_bytes(&companions.build_evidence);
+ joins::check_build_evidence(evidence, entry, &index, &modules)
+ .expect("should join build evidence to the index and inventory");
+ let spdx = candidate.present_bytes(&companions.sbom);
+ let predicate = parse_json(spdx, Role::Spdx).expect("should parse the candidate SPDX");
+ joins::check_spdx(&predicate, spdx, &entry.sha256)
+ .expect("should bind the SPDX root to the executable digest");
+ let outcome = joins::parse_comparison(candidate.present_bytes(&companions.rebuild_check))
+ .expect("should parse the rebuild outcome");
+ let expected = if entry.name == "trusted-server-fastly.wasm" {
+ ComparisonOutcome::Matched
+ } else {
+ ComparisonOutcome::Unavailable
+ };
+ assert_eq!(outcome, expected, "should record {}", entry.name);
+ let recipe = joins::parse_informational(candidate.present_bytes(&companions.build_recipe))
+ .expect("should parse the informational recipe");
+ for document in [
+ recipe,
+ parse_json(evidence, Role::Companion).expect("should parse evidence"),
+ ] {
+ assert_eq!(
+ document["release_qualification"],
+ Value::Bool(false),
+ "should state the candidate is unqualified"
+ );
+ }
+ }
+}
+
+/// Requires every bundle slot of `candidate` to be the empty-file sentinel
+/// that full verification rejects.
+fn check_empty_bundle_sentinels(candidate: &CandidateFiles) {
+ let policy = embedded_policy().expect("should accept the embedded consumer policy");
+ let index = parse_release_index(
+ candidate.bytes(CANDIDATE_INDEX_NAME),
+ &policy,
+ &candidate.approved(),
+ )
+ .expect("should parse the candidate index");
+ let empty = sha256_hex(b"");
+ for entry in &index.artifacts {
+ for record in [
+ &entry.companions.provenance,
+ &entry.companions.sbom_attestation,
+ ] {
+ assert_eq!(
+ (record.size, record.sha256.as_str()),
+ (0, empty.as_str()),
+ "should record the true empty file for {}",
+ record.name
+ );
+ }
+ let error = check_bundle_records(entry).expect_err("should reject empty bundles");
+ assert_eq!(
+ *error.current_context(),
+ ReleaseError::Evidence,
+ "should fail closed as an evidence error"
+ );
+ assert!(
+ format!("{error:?}").contains("is empty"),
+ "should name the empty bundle: {error:?}"
+ );
+ }
+}
+
+/// A placeholder proof bound to `release`; the sentinel-tag rule must reject
+/// before any proof field is read.
+fn placeholder_proof(
+ release: &ApprovedRelease,
+ predicate: PredicateKind,
+ subject: &AssetMeasurement,
+) -> VerifiedAttestation {
+ VerifiedAttestation::synthetic_for_tests(
+ json!({}),
+ release.clone(),
+ predicate,
+ subject.clone(),
+ AssetMeasurement::of_bytes(b"fictional placeholder bundle"),
+ )
+}
+
+/// Rewrites every bundle record of `index_bytes` as a non-empty record, so
+/// the empty-bundle rule alone can no longer reject the index.
+fn with_non_empty_bundle_records(index_bytes: &[u8]) -> Vec {
+ let mut index: Value =
+ serde_json::from_slice(index_bytes).expect("should parse the candidate index");
+ let bundle = b"fictional non-empty signature bundle";
+ for artifact in index["artifacts"]
+ .as_array_mut()
+ .expect("should hold candidate artifacts")
+ {
+ for slot in ["provenance", "sbom_attestation"] {
+ let record = &mut artifact["companions"][slot];
+ record["size"] = json!(bundle.len());
+ record["sha256"] = json!(sha256_hex(bundle));
+ }
+ }
+ serde_json::to_vec(&index).expect("should serialize the altered index")
+}
+
+/// Runs full build-evidence verification over `index_bytes` and the golden
+/// Fastly companions, under the sentinel tag.
+fn verify_candidate(
+ candidate: &CandidateFiles,
+ index_bytes: &[u8],
+) -> Result> {
+ let release = candidate.approved();
+ let policy = embedded_policy().expect("should accept the embedded consumer policy");
+ let source =
+ ApprovedSourcePolicy::for_tests(candidate.approved_commit(), EMBEDDED_POLICY.to_vec());
+ let asset = "trusted-server-fastly.wasm";
+ let executable = AssetMeasurement::of_bytes(b"fictional candidate executable");
+ let bundle = AssetMeasurement::of_bytes(b"fictional placeholder bundle");
+ let index_measurement = AssetMeasurement::of_bytes(index_bytes);
+ let index_proof = placeholder_proof(&release, PredicateKind::Provenance, &index_measurement);
+ let provenance_proof = placeholder_proof(&release, PredicateKind::Provenance, &executable);
+ let spdx_proof = placeholder_proof(&release, PredicateKind::Spdx, &executable);
+ let artifacts = [ConsumedArtifact {
+ name: asset,
+ executable: &executable,
+ spdx: candidate.bytes(&format!("{asset}.spdx.json")),
+ build_evidence: candidate.bytes(&format!("{asset}.build-evidence.json")),
+ build_recipe: candidate.bytes(&format!("{asset}.build-recipe.json")),
+ rebuild_check: candidate.bytes(&format!("{asset}.rebuild-check.json")),
+ provenance_bundle: &bundle,
+ sbom_bundle: &bundle,
+ provenance_proof: &provenance_proof,
+ spdx_proof: &spdx_proof,
+ }];
+ verify_build_evidence(&EvidenceInputs {
+ release: &release,
+ consumer_policy: &policy,
+ source_policy: &source,
+ index_bytes,
+ index_proof: &index_proof,
+ index_bundle: &bundle,
+ inventory_bytes: candidate.bytes(INVENTORY_NAME),
+ artifacts: &artifacts,
+ })
+}
+
+/// Requires `result` to fail as the sentinel-tag policy rejection.
+fn assert_sentinel_rejection(result: Result>) {
+ let error = result.expect_err("should reject the candidate sentinel tag");
+ assert_eq!(
+ *error.current_context(),
+ ReleaseError::Policy,
+ "should fail closed as a policy rejection"
+ );
+ assert!(
+ format!("{error:?}").contains("unqualified candidate sentinel tag"),
+ "should name the sentinel tag: {error:?}"
+ );
+}
+
+#[test]
+fn full_verification_rejects_the_golden_candidate_for_its_sentinel_tag() {
+ let candidate = CandidateFiles::committed();
+
+ assert_sentinel_rejection(verify_candidate(
+ &candidate,
+ candidate.bytes(CANDIDATE_INDEX_NAME),
+ ));
+}
+
+#[test]
+fn full_verification_rejects_the_sentinel_tag_even_with_non_empty_bundle_records() {
+ let candidate = CandidateFiles::committed();
+ let index_bytes = with_non_empty_bundle_records(candidate.bytes(CANDIDATE_INDEX_NAME));
+ let policy = embedded_policy().expect("should accept the embedded consumer policy");
+ let index = parse_release_index(&index_bytes, &policy, &candidate.approved())
+ .expect("should parse the altered candidate index");
+ index
+ .artifacts
+ .iter()
+ .try_for_each(check_bundle_records)
+ .expect("should pass the empty-bundle rule once bundles are non-empty");
+
+ assert_sentinel_rejection(verify_candidate(&candidate, &index_bytes));
+ let release = candidate.approved();
+ let index_measurement = AssetMeasurement::of_bytes(&index_bytes);
+ assert_sentinel_rejection(verify_index(
+ &release,
+ &policy,
+ &ApprovedSourcePolicy::for_tests(candidate.approved_commit(), EMBEDDED_POLICY.to_vec()),
+ &index_bytes,
+ &placeholder_proof(&release, PredicateKind::Provenance, &index_measurement),
+ &AssetMeasurement::of_bytes(b"fictional placeholder bundle"),
+ ));
+}
+
+#[test]
+fn golden_candidate_companions_fail_the_release_qualification_rule() {
+ let candidate = CandidateFiles::committed();
+ let index_bytes = with_non_empty_bundle_records(candidate.bytes(CANDIDATE_INDEX_NAME));
+ let policy = embedded_policy().expect("should accept the embedded consumer policy");
+ let release = candidate.approved();
+ let index = parse_release_index(&index_bytes, &policy, &release)
+ .expect("should parse the altered candidate index");
+ let executable = AssetMeasurement::of_bytes(b"fictional candidate executable");
+ let bundle = AssetMeasurement::of_bytes(b"fictional placeholder bundle");
+ let proof = placeholder_proof(&release, PredicateKind::Provenance, &executable);
+ for entry in &index.artifacts {
+ let companions = &entry.companions;
+ let consumed = ConsumedArtifact {
+ name: &entry.name,
+ executable: &executable,
+ spdx: candidate.present_bytes(&companions.sbom),
+ build_evidence: candidate.present_bytes(&companions.build_evidence),
+ build_recipe: candidate.present_bytes(&companions.build_recipe),
+ rebuild_check: candidate.present_bytes(&companions.rebuild_check),
+ provenance_bundle: &bundle,
+ sbom_bundle: &bundle,
+ provenance_proof: &proof,
+ spdx_proof: &proof,
+ };
+
+ let error = check_release_qualification(entry, &consumed)
+ .expect_err("should reject the unqualified candidate companions");
+
+ assert_eq!(
+ *error.current_context(),
+ ReleaseError::Evidence,
+ "should fail closed as an evidence error for {}",
+ entry.name
+ );
+ assert!(
+ format!("{error:?}").contains("declares `release_qualification: false`"),
+ "should name the unqualified declaration: {error:?}"
+ );
+ }
+}
+
+#[test]
+fn candidate_index_passes_every_unsigned_consumer_join() {
+ check_unsigned_consumer_joins(&CandidateFiles::committed());
+}
+
+#[test]
+fn candidate_bundles_are_empty_sentinels_that_verification_rejects() {
+ check_empty_bundle_sentinels(&CandidateFiles::committed());
+}
+
+#[test]
+fn directory_reader_reads_exactly_the_eighteen_candidate_files() {
+ let scratch = tempfile::tempdir().expect("should create a scratch directory");
+ for (name, bytes) in FIXTURES {
+ std::fs::write(scratch.path().join(name), bytes).expect("should write the fixture");
+ }
+
+ let candidate = CandidateFiles::from_directory(scratch.path(), COMMIT.to_owned());
+
+ assert_eq!(
+ candidate.files,
+ CandidateFiles::committed().files,
+ "should read exactly the committed bytes back"
+ );
+ check_unsigned_consumer_joins(&candidate);
+ check_empty_bundle_sentinels(&candidate);
+}
+
+#[test]
+#[ignore = "requires the candidate index the ordinary-CI collector assembled from four real candidate jobs"]
+fn ordinary_ci_assembled_candidate_passes_unsigned_consumer_joins() {
+ let root = std::env::var_os("TS_RELEASE_CANDIDATE_INDEX_ROOT")
+ .expect("should explicitly supply the assembled candidate index directory");
+ let commit = std::env::var("TS_RELEASE_CANDIDATE_SOURCE_COMMIT")
+ .expect("should explicitly supply the candidate source commit");
+ let candidate = CandidateFiles::from_directory(Path::new(&root), commit);
+
+ check_unsigned_consumer_joins(&candidate);
+ check_empty_bundle_sentinels(&candidate);
+}
diff --git a/crates/trusted-server-cli/src/release/evidence/joins.rs b/crates/trusted-server-cli/src/release/evidence/joins.rs
new file mode 100644
index 0000000000..bd3b47db71
--- /dev/null
+++ b/crates/trusted-server-cli/src/release/evidence/joins.rs
@@ -0,0 +1,300 @@
+//! Document joins of the evidence graph: JS inventory, build evidence, SPDX
+//! root/document equality and the informational rebuild outcome.
+//!
+//! Every document is parsed with the strict byte-first parser, so duplicate
+//! keys, BOMs, excess depth and oversize input are rejected before any join.
+//! Only the fields the joins need are read; their names follow the R-INDEX
+//! field contracts because no closed build-evidence, inventory or
+//! rebuild-check schema is retained yet.
+
+use error_stack::Report;
+use serde_json::Value;
+
+use super::{at, evidence_error, object};
+use crate::release::documents::{IndexArtifact, ReleaseIndex, Role, lower_hex, parse_json};
+use crate::release::error::ReleaseError;
+
+/// SPDX document element identifier.
+const DOCUMENT_ID: &str = "SPDXRef-DOCUMENT";
+
+/// One JS module identity shared by the inventory and build evidence.
+#[derive(Clone, Debug, PartialEq, Eq)]
+pub(crate) struct ModuleRecord {
+ id: String,
+ filename: String,
+ size: u64,
+ sha256: String,
+}
+
+/// Informational rebuild comparison outcome; never an authorization input.
+#[derive(Clone, Copy, Debug, PartialEq, Eq, derive_more::Display)]
+pub(crate) enum ComparisonOutcome {
+ #[display("matched")]
+ Matched,
+ #[display("mismatched")]
+ Mismatched,
+ #[display("failed")]
+ Failed,
+ #[display("unavailable")]
+ Unavailable,
+}
+
+fn module_record(value: &Value) -> Result> {
+ let text = |key: &str| value.get(key).and_then(Value::as_str);
+ let record = ModuleRecord {
+ id: text("id").unwrap_or_default().to_owned(),
+ filename: text("filename").unwrap_or_default().to_owned(),
+ size: value
+ .get("size")
+ .and_then(Value::as_u64)
+ .unwrap_or(u64::MAX),
+ sha256: text("sha256").unwrap_or_default().to_owned(),
+ };
+ if record.id.is_empty()
+ || record.filename.is_empty()
+ || record.size == u64::MAX
+ || !lower_hex(&record.sha256, 64)
+ {
+ return Err(evidence_error(
+ "JS module record needs id, filename, integer size and SHA-256",
+ ));
+ }
+ Ok(record)
+}
+
+/// Parses an array of module records and requires strictly ascending ids.
+fn sorted_modules(
+ value: Option<&Value>,
+ what: &str,
+) -> Result, Report> {
+ let records = value
+ .and_then(Value::as_array)
+ .ok_or_else(|| evidence_error(format!("{what} must be an array")))?
+ .iter()
+ .map(module_record)
+ .collect::, _>>()?;
+ if records.windows(2).any(|pair| match pair {
+ [first, second] => first.id >= second.id,
+ _ => false,
+ }) {
+ return Err(evidence_error(format!(
+ "{what} must be sorted by unique module id"
+ )));
+ }
+ Ok(records)
+}
+
+fn require_version_and_source(
+ document: &Value,
+ commit: &str,
+ what: &str,
+) -> Result<(), Report> {
+ object(document, what)?;
+ if document.get("schema_version").and_then(Value::as_u64) != Some(1) {
+ return Err(evidence_error(format!("{what} schema version must be 1")));
+ }
+ if document.get("source_commit").and_then(Value::as_str) != Some(commit) {
+ return Err(evidence_error(format!(
+ "{what} source commit is not the approved commit"
+ )));
+ }
+ Ok(())
+}
+
+/// Parses the shared JS inventory and returns its module table.
+///
+/// # Errors
+///
+/// Returns [`ReleaseError::Document`] for malformed JSON and
+/// [`ReleaseError::Evidence`] for another source commit or a malformed,
+/// unsorted or duplicated module table.
+pub(crate) fn parse_inventory(
+ bytes: &[u8],
+ commit: &str,
+) -> Result, Report> {
+ let inventory = parse_json(bytes, Role::Inventory)?;
+ require_version_and_source(&inventory, commit, "JS inventory")?;
+ let modules = sorted_modules(inventory.get("modules"), "JS inventory modules")?;
+ if modules.is_empty() {
+ return Err(evidence_error("JS inventory lists no modules"));
+ }
+ Ok(modules)
+}
+
+/// Joins one artifact's build evidence to the index and shared inventory.
+///
+/// # Errors
+///
+/// Returns [`ReleaseError::Evidence`] when the build evidence names another
+/// source, target, variant, executable or inventory digest, when its prepared
+/// module table differs from the inventory, or when a claimed retained module
+/// is not exactly an inventory module.
+pub(crate) fn check_build_evidence(
+ bytes: &[u8],
+ entry: &IndexArtifact,
+ index: &ReleaseIndex,
+ modules: &[ModuleRecord],
+) -> Result<(), Report> {
+ let evidence = parse_json(bytes, Role::Companion)?;
+ require_version_and_source(&evidence, &index.source_commit, "build evidence")?;
+ let joins = [
+ ("target", entry.target.as_str()),
+ ("variant", entry.variant.as_str()),
+ ("artifact_sha256", entry.sha256.as_str()),
+ ("js_inventory_sha256", index.tsjs_inventory.sha256.as_str()),
+ ];
+ for (field, wanted) in joins {
+ if evidence.get(field).and_then(Value::as_str) != Some(wanted) {
+ return Err(evidence_error(format!(
+ "build evidence `{field}` differs from the signed index"
+ )));
+ }
+ }
+ let prepared = sorted_modules(evidence.get("prepared_modules"), "prepared modules")?;
+ if prepared != modules {
+ return Err(evidence_error(
+ "prepared JS modules differ from the shared inventory",
+ ));
+ }
+ let retained = sorted_modules(evidence.get("embedded_modules"), "embedded modules")?;
+ if let Some(unknown) = retained.iter().find(|module| !modules.contains(module)) {
+ return Err(evidence_error(format!(
+ "retained JS module `{}` is not exactly an inventory module",
+ unknown.id
+ )));
+ }
+ Ok(())
+}
+
+/// Checks the SPDX predicate against the published document and executable.
+///
+/// The published document is parsed strictly (duplicate keys rejected) and
+/// compared as parsed JSON: object key order is ignored, array order is
+/// significant. File-byte digest equality is checked separately against the
+/// signed index; this comparison never substitutes for it.
+///
+/// # Errors
+///
+/// Returns [`ReleaseError::Evidence`] if the predicate differs from the
+/// document or the document root checksum is not the executable digest.
+pub(crate) fn check_spdx(
+ predicate: &Value,
+ document_bytes: &[u8],
+ executable_sha256: &str,
+) -> Result<(), Report> {
+ let document = parse_json(document_bytes, Role::Spdx)?;
+ if predicate != &document {
+ return Err(evidence_error(
+ "SPDX predicate differs from the published SPDX document",
+ ));
+ }
+ if at(&document, &["spdxVersion"]).and_then(Value::as_str) != Some("SPDX-2.3")
+ || at(&document, &["SPDXID"]).and_then(Value::as_str) != Some(DOCUMENT_ID)
+ {
+ return Err(evidence_error("published SBOM is not an SPDX 2.3 document"));
+ }
+ let root = spdx_root(&document)?;
+ if root_sha256(&document, &root)? != executable_sha256 {
+ return Err(evidence_error(
+ "SPDX root package checksum differs from the executable digest",
+ ));
+ }
+ Ok(())
+}
+
+/// Returns the single element the document describes.
+fn spdx_root(document: &Value) -> Result> {
+ let mut described: Vec<&str> = document
+ .get("documentDescribes")
+ .and_then(Value::as_array)
+ .map(|ids| ids.iter().filter_map(Value::as_str).collect())
+ .unwrap_or_default();
+ let relationships = document
+ .get("relationships")
+ .and_then(Value::as_array)
+ .map(Vec::as_slice)
+ .unwrap_or_default();
+ described.extend(relationships.iter().filter_map(|relationship| {
+ let field = |key: &str| relationship.get(key).and_then(Value::as_str);
+ (field("spdxElementId") == Some(DOCUMENT_ID)
+ && field("relationshipType") == Some("DESCRIBES"))
+ .then(|| field("relatedSpdxElement"))
+ .flatten()
+ }));
+ described.sort_unstable();
+ described.dedup();
+ match described.as_slice() {
+ [root] => Ok((*root).to_owned()),
+ _ => Err(evidence_error(
+ "SPDX document must describe exactly one root",
+ )),
+ }
+}
+
+fn root_sha256<'a>(document: &'a Value, root: &str) -> Result<&'a str, Report> {
+ let packages: Vec<&Value> = document
+ .get("packages")
+ .and_then(Value::as_array)
+ .map(|packages| {
+ packages
+ .iter()
+ .filter(|package| package.get("SPDXID").and_then(Value::as_str) == Some(root))
+ .collect()
+ })
+ .unwrap_or_default();
+ let [package] = packages.as_slice() else {
+ return Err(evidence_error("SPDX root must be exactly one package"));
+ };
+ let checksums: Vec<&str> = package
+ .get("checksums")
+ .and_then(Value::as_array)
+ .map(|checksums| {
+ checksums
+ .iter()
+ .filter(|checksum| {
+ checksum.get("algorithm").and_then(Value::as_str) == Some("SHA256")
+ })
+ .filter_map(|checksum| checksum.get("checksumValue").and_then(Value::as_str))
+ .collect()
+ })
+ .unwrap_or_default();
+ match checksums.as_slice() {
+ [sha256] => Ok(sha256),
+ _ => Err(evidence_error(
+ "SPDX root package must carry exactly one SHA256 checksum",
+ )),
+ }
+}
+
+/// Parses the informational rebuild-check outcome.
+///
+/// # Errors
+///
+/// Returns [`ReleaseError::Evidence`] for an outcome outside the closed enum.
+pub(crate) fn parse_comparison(bytes: &[u8]) -> Result> {
+ let document = parse_informational(bytes)?;
+ match document.get("outcome").and_then(Value::as_str) {
+ Some("matched") => Ok(ComparisonOutcome::Matched),
+ Some("mismatched") => Ok(ComparisonOutcome::Mismatched),
+ Some("failed") => Ok(ComparisonOutcome::Failed),
+ Some("unavailable") => Ok(ComparisonOutcome::Unavailable),
+ _ => Err(evidence_error(
+ "rebuild check outcome must be matched, mismatched, failed or unavailable",
+ )),
+ }
+}
+
+/// Strictly parses a version-1 informational companion without executing it.
+///
+/// # Errors
+///
+/// Returns a document error for invalid JSON or an evidence error for an
+/// unsupported version.
+pub(super) fn parse_informational(bytes: &[u8]) -> Result> {
+ let document = parse_json(bytes, Role::Companion)?;
+ object(&document, "informational companion")?;
+ if document.get("version").and_then(Value::as_u64) != Some(1) {
+ return Err(evidence_error("informational companion version must be 1"));
+ }
+ Ok(document)
+}
diff --git a/crates/trusted-server-cli/src/release/evidence/upstream_format_tests.rs b/crates/trusted-server-cli/src/release/evidence/upstream_format_tests.rs
new file mode 100644
index 0000000000..aeec296a94
--- /dev/null
+++ b/crates/trusted-server-cli/src/release/evidence/upstream_format_tests.rs
@@ -0,0 +1,66 @@
+//! Fictionalized format observations from separately verified upstream proofs.
+//!
+//! These JSON values contain no signatures and supply no verification authority.
+//! Actual cryptographic qualification is recorded separately under
+//! `scripts/release/qualification/`; these tests protect parser compatibility.
+
+use std::collections::BTreeMap;
+
+use serde_json::Value;
+
+use super::{Expected, check_certificate, check_subjects, statement};
+use crate::release::documents::{ApprovedCommit, ApprovedRelease, ReleaseTag, Role, parse_json};
+use crate::release::policy::{PROVENANCE_PREDICATE, SBOM_PREDICATE};
+
+const FORMATS: &[u8] = include_bytes!("../../../tests/fixtures/release/upstream-formats.json");
+
+fn observed(kind: &str) -> Value {
+ let formats = parse_json(FORMATS, Role::GhOutput)
+ .expect("should parse fictionalized upstream format observations");
+ formats[kind][0].clone()
+}
+
+#[test]
+fn pinned_upstream_provenance_and_spdx_shapes_reach_subject_checks() {
+ let digest = "a".repeat(64);
+ let expected = BTreeMap::from([("example-asset", digest.as_str())]);
+ for (kind, predicate) in [
+ ("provenance", PROVENANCE_PREDICATE),
+ ("spdx", SBOM_PREDICATE),
+ ] {
+ let proof = observed(kind);
+ let statement = statement(&proof, predicate)
+ .expect("should accept the observed pinned verification result shape");
+ check_subjects(statement, &expected)
+ .expect("should bind the observed subject shape to exact bytes");
+ let changed = BTreeMap::from([("another-asset", digest.as_str())]);
+ assert!(
+ check_subjects(statement, &changed).is_err(),
+ "should reject a subject name outside the expected set"
+ );
+ }
+}
+
+#[test]
+fn upstream_format_never_confers_official_repository_identity() {
+ let release = ApprovedRelease::new(
+ ReleaseTag::parse("v1.2.0").expect("should parse the fictional tag"),
+ ApprovedCommit::parse("0123456789abcdef0123456789abcdef01234567")
+ .expect("should parse the fictional commit"),
+ );
+ let expected = Expected::new(&release);
+ for kind in ["provenance", "spdx"] {
+ assert!(
+ check_certificate(&observed(kind), &expected).is_err(),
+ "should reject an unrelated signer despite compatible JSON"
+ );
+ }
+}
+
+#[test]
+fn observed_spdx_predicate_cannot_substitute_for_provenance() {
+ assert!(
+ statement(&observed("spdx"), PROVENANCE_PREDICATE).is_err(),
+ "should keep the observed predicate roles distinct"
+ );
+}
diff --git a/crates/trusted-server-cli/src/release/gh.rs b/crates/trusted-server-cli/src/release/gh.rs
new file mode 100644
index 0000000000..c4640471ef
--- /dev/null
+++ b/crates/trusted-server-cli/src/release/gh.rs
@@ -0,0 +1,765 @@
+//! Bounded, isolated invocation of the pinned GitHub CLI.
+//!
+//! gh is resolved once (explicit trusted path or one trusted `PATH` lookup)
+//! to a canonical executable that every later invocation reuses. Its
+//! `--version` text must report exactly the qualified version; that is a
+//! compatibility diagnostic only, because version text never authenticates an
+//! executable.
+//!
+//! Each child gets a cleared environment carrying only the explicit token (as
+//! `GH_TOKEN`), private home/config/state/cache directories and disabled
+//! prompts. It runs in its own process group with fixed argument vectors, a
+//! per-child and per-attempt deadline, cancellation, and concurrently drained
+//! byte-capped pipes. On any limit the whole group is killed and the leader
+//! reaped; a descendant that escaped the group and still holds the pipes is
+//! reported instead of awaited. A failed exit never yields parsed claims.
+
+use std::ffi::OsString;
+use std::fs::DirBuilder;
+use std::os::unix::fs::{DirBuilderExt as _, PermissionsExt as _};
+use std::os::unix::process::CommandExt as _;
+use std::path::{Path, PathBuf};
+use std::process::{Command, Stdio};
+
+use error_stack::Report;
+use serde_json::Value;
+use tempfile::TempDir;
+
+use super::artifact::AssetMeasurement;
+use super::bounds::{self, AttemptDeadline, Cancellation, ProcessBounds};
+use super::documents::{ApprovedRelease, ReleaseTag, Role, parse_json};
+use super::environment::{CallerEnvironment, GithubToken};
+use super::error::{ReleaseError, report};
+use super::policy::{
+ GH_VERSION, GITHUB_HOST, ISSUER, PROVENANCE_PREDICATE, REPOSITORY, SBOM_PREDICATE,
+ certificate_identity,
+};
+use super::process::supervise;
+
+mod input;
+
+/// Trust context of a successfully verified build attestation.
+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+pub(crate) enum VerificationMode {
+ /// Uses the online verifier trust configuration.
+ Online,
+ /// Uses independently transferred, pinned roots; cannot prove publication.
+ Offline,
+}
+
+/// One successful pinned gh attestation invocation, bound to its exact inputs.
+///
+/// Only this module constructs production proofs. Callers cannot deserialize
+/// or assert a successful verifier result.
+#[derive(Debug)]
+pub(crate) struct VerifiedAttestation {
+ mode: VerificationMode,
+ result: Value,
+ release: ApprovedRelease,
+ predicate: PredicateKind,
+ subject: AssetMeasurement,
+ bundle: AssetMeasurement,
+}
+
+impl VerifiedAttestation {
+ /// Returns the immutable trust context of the verification.
+ pub(crate) fn mode(&self) -> VerificationMode {
+ self.mode
+ }
+ /// Returns verified claims only for the same approved release and bytes.
+ ///
+ /// # Errors
+ ///
+ /// Returns an evidence error when any invocation input differs.
+ pub(crate) fn for_inputs(
+ &self,
+ release: &ApprovedRelease,
+ predicate: PredicateKind,
+ subject: &AssetMeasurement,
+ bundle: &AssetMeasurement,
+ ) -> Result<&Value, Report> {
+ if !same_release(&self.release, release)
+ || self.predicate != predicate
+ || &self.subject != subject
+ || &self.bundle != bundle
+ {
+ return Err(report(
+ ReleaseError::Evidence,
+ "verified gh proof belongs to other inputs",
+ ));
+ }
+ Ok(&self.result)
+ }
+
+ /// Constructs deliberately synthetic evidence for semantic tests only.
+ #[cfg(test)]
+ pub(crate) fn synthetic_for_tests(
+ result: Value,
+ release: ApprovedRelease,
+ predicate: PredicateKind,
+ subject: AssetMeasurement,
+ bundle: AssetMeasurement,
+ ) -> Self {
+ Self {
+ mode: VerificationMode::Online,
+ result,
+ release,
+ predicate,
+ subject,
+ bundle,
+ }
+ }
+}
+
+/// Successful gh release verification, bound to independent release approval.
+#[derive(Debug)]
+pub(crate) struct VerifiedReleaseStatement {
+ result: Value,
+ release: ApprovedRelease,
+}
+
+impl VerifiedReleaseStatement {
+ /// Returns verified claims for the independently approved release.
+ ///
+ /// # Errors
+ ///
+ /// Returns a publication error for a different release.
+ pub(crate) fn for_release(
+ &self,
+ release: &ApprovedRelease,
+ ) -> Result<&Value, Report> {
+ if !same_release(&self.release, release) {
+ return Err(report(
+ ReleaseError::Publication,
+ "verified release proof belongs to another release",
+ ));
+ }
+ Ok(&self.result)
+ }
+
+ /// Constructs deliberately synthetic publication evidence for tests only.
+ #[cfg(test)]
+ pub(crate) fn synthetic_for_tests(result: Value, release: ApprovedRelease) -> Self {
+ Self { result, release }
+ }
+}
+
+fn same_release(first: &ApprovedRelease, second: &ApprovedRelease) -> bool {
+ first.tag().as_str() == second.tag().as_str()
+ && first.commit().as_str() == second.commit().as_str()
+}
+
+const MINIMAL_PATH: &str = "/usr/bin:/bin";
+const STDERR_EXCERPT_BYTES: usize = 4096;
+const VERSION_EXCERPT_CHARS: usize = 128;
+/// `gh release` subcommands take a host-qualified repository.
+fn release_repository() -> String {
+ format!("{GITHUB_HOST}/{REPOSITORY}")
+}
+
+/// Where to find the gh executable.
+#[derive(Clone, Copy, Debug)]
+pub(crate) enum GhLocation<'a> {
+ /// An absolute path from trusted installer configuration.
+ Explicit(&'a Path),
+ /// One lookup through the absolute entries of the snapshot's `PATH`.
+ SearchPath,
+}
+
+/// The attestation predicate a verification requires.
+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+pub(crate) enum PredicateKind {
+ /// SLSA build provenance.
+ Provenance,
+ /// SPDX SBOM.
+ Spdx,
+}
+
+impl PredicateKind {
+ fn bundle_suffix(self) -> &'static str {
+ match self {
+ Self::Provenance => ".provenance.sigstore.json",
+ Self::Spdx => ".sbom.sigstore.json",
+ }
+ }
+
+ fn uri(self) -> &'static str {
+ match self {
+ Self::Provenance => PROVENANCE_PREDICATE,
+ Self::Spdx => SBOM_PREDICATE,
+ }
+ }
+}
+
+fn absolute(path: &Path, what: &str) -> Result> {
+ if !path.is_absolute() {
+ return Err(report(
+ ReleaseError::Policy,
+ format!("{what} path must be absolute"),
+ ));
+ }
+ Ok(path.as_os_str().to_owned())
+}
+
+/// Builds the fixed `gh attestation verify` argument vector.
+///
+/// `bundle` must be the retained, membership-checked companion bundle for
+/// `predicate` (`.provenance.sigstore.json` or `.sbom.sigstore.json`), so gh
+/// verifies exactly that bundle instead of fetching attestations itself.
+///
+/// # Errors
+///
+/// Returns [`ReleaseError::Policy`] unless `subject` and `bundle` are absolute
+/// (so neither can be parsed as a flag) and the bundle name matches the
+/// predicate.
+pub(crate) fn attestation_verify_args(
+ subject: &Path,
+ bundle: &Path,
+ release: &ApprovedRelease,
+ predicate: PredicateKind,
+) -> Result, Report> {
+ let subject = absolute(subject, "attestation subject")?;
+ let bundle_name = bundle.file_name().map(|name| name.to_string_lossy());
+ if !bundle_name.is_some_and(|name| name.ends_with(predicate.bundle_suffix())) {
+ return Err(report(
+ ReleaseError::Policy,
+ "attestation bundle does not match the requested predicate",
+ ));
+ }
+ let bundle = absolute(bundle, "attestation bundle")?;
+ let commit = release.commit().as_str();
+ let fixed = ["attestation".to_owned(), "verify".to_owned()].map(OsString::from);
+ let options = [
+ "-R".to_owned(),
+ REPOSITORY.to_owned(),
+ "--hostname".to_owned(),
+ GITHUB_HOST.to_owned(),
+ "--cert-identity".to_owned(),
+ certificate_identity(release.tag()),
+ "--cert-oidc-issuer".to_owned(),
+ ISSUER.to_owned(),
+ "--source-ref".to_owned(),
+ format!("refs/tags/{}", release.tag().as_str()),
+ "--source-digest".to_owned(),
+ commit.to_owned(),
+ "--signer-digest".to_owned(),
+ commit.to_owned(),
+ "--deny-self-hosted-runners".to_owned(),
+ "--predicate-type".to_owned(),
+ predicate.uri().to_owned(),
+ "--format".to_owned(),
+ "json".to_owned(),
+ ]
+ .map(OsString::from);
+ Ok(fixed
+ .into_iter()
+ .chain([subject, OsString::from("--bundle"), bundle])
+ .chain(options)
+ .collect())
+}
+
+/// Builds the fixed `gh release verify` argument vector.
+pub(crate) fn release_verify_args(tag: &ReleaseTag) -> Vec {
+ vec![
+ OsString::from("release"),
+ OsString::from("verify"),
+ OsString::from(tag.as_str()),
+ OsString::from("-R"),
+ OsString::from(release_repository()),
+ OsString::from("--format"),
+ OsString::from("json"),
+ ]
+}
+
+/// Builds the fixed `gh release verify-asset` argument vector.
+///
+/// # Errors
+///
+/// Returns [`ReleaseError::Policy`] unless `asset` is absolute.
+pub(crate) fn release_verify_asset_args(
+ tag: &ReleaseTag,
+ asset: &Path,
+) -> Result, Report> {
+ let asset = absolute(asset, "release asset")?;
+ Ok(vec![
+ OsString::from("release"),
+ OsString::from("verify-asset"),
+ OsString::from(tag.as_str()),
+ asset,
+ OsString::from("-R"),
+ OsString::from(release_repository()),
+ ])
+}
+
+/// Captured output of a successful child.
+#[derive(Debug)]
+pub(crate) struct ChildOutput {
+ pub(crate) stdout: Vec,
+ /// Lossily decoded stderr with the credential redacted.
+ pub(crate) stderr: String,
+}
+
+/// Private directories and the reviewed non-secret variables for every gh child.
+///
+/// The credential is never stored here; it stays in [`GithubToken`] (whose
+/// `Debug` is redacted) and is injected only when a child is spawned.
+#[derive(Debug)]
+struct ChildEnvironment {
+ root: TempDir,
+ variables: Vec<(OsString, OsString)>,
+}
+
+/// Fixed non-secret settings for every gh child.
+const FIXED_VARIABLES: [(&str, &str); 8] = [
+ ("GH_PROMPT_DISABLED", "1"),
+ ("GH_NO_UPDATE_NOTIFIER", "1"),
+ ("GH_NO_EXTENSION_UPDATE_NOTIFIER", "1"),
+ ("GH_SPINNER_DISABLED", "1"),
+ ("DO_NOT_TRACK", "1"),
+ ("NO_COLOR", "1"),
+ ("LC_ALL", "C"),
+ ("PATH", MINIMAL_PATH),
+];
+
+/// Private directory variables, relative to the isolated root.
+const PRIVATE_DIRECTORIES: [(&str, &str); 7] = [
+ ("GH_CONFIG_DIR", "config/gh"),
+ ("XDG_CONFIG_HOME", "config"),
+ ("XDG_STATE_HOME", "state"),
+ ("XDG_CACHE_HOME", "cache"),
+ ("XDG_DATA_HOME", "data"),
+ ("TMPDIR", "tmp"),
+ ("HOME", "home"),
+];
+
+fn reject_loader_injection(caller: &CallerEnvironment) -> Result<(), Report> {
+ let injected = caller.names().find(|name| {
+ let name = name.to_string_lossy();
+ name.starts_with("LD_") || name.starts_with("DYLD_")
+ });
+ match injected {
+ Some(name) => Err(report(
+ ReleaseError::Environment,
+ format!(
+ "dynamic-loader variable {} is set; refusing to run gh",
+ name.to_string_lossy()
+ ),
+ )),
+ None => Ok(()),
+ }
+}
+
+impl ChildEnvironment {
+ fn isolated(caller: &CallerEnvironment) -> Result> {
+ reject_loader_injection(caller)?;
+ let root = tempfile::Builder::new()
+ .prefix("ts-release-gh-")
+ .tempdir()
+ .map_err(|source| report(ReleaseError::Environment, source))?;
+ let mut variables = Vec::with_capacity(PRIVATE_DIRECTORIES.len() + FIXED_VARIABLES.len());
+ for (name, relative) in PRIVATE_DIRECTORIES {
+ let path = root.path().join(relative);
+ DirBuilder::new()
+ .recursive(true)
+ .mode(0o700)
+ .create(&path)
+ .map_err(|source| report(ReleaseError::Environment, source))?;
+ variables.push((OsString::from(name), path.into_os_string()));
+ }
+ variables.extend(
+ FIXED_VARIABLES.map(|(name, value)| (OsString::from(name), OsString::from(value))),
+ );
+ Ok(Self { root, variables })
+ }
+}
+
+fn is_executable_file(path: &Path) -> bool {
+ std::fs::metadata(path)
+ .is_ok_and(|metadata| metadata.is_file() && metadata.permissions().mode() & 0o111 != 0)
+}
+
+fn locate(
+ location: GhLocation<'_>,
+ caller: &CallerEnvironment,
+) -> Result> {
+ let candidate = match location {
+ GhLocation::Explicit(path) => {
+ if !path.is_absolute() {
+ return Err(report(
+ ReleaseError::UnsupportedTool,
+ "explicit gh path must be absolute",
+ ));
+ }
+ path.to_path_buf()
+ }
+ GhLocation::SearchPath => caller
+ .get("PATH")
+ .and_then(|search| {
+ std::env::split_paths(search)
+ .filter(|directory| directory.is_absolute())
+ .map(|directory| directory.join("gh"))
+ .find(|candidate| is_executable_file(candidate))
+ })
+ .ok_or_else(|| {
+ report(
+ ReleaseError::UnsupportedTool,
+ "gh was not found in any absolute PATH entry",
+ )
+ })?,
+ };
+ let canonical = candidate
+ .canonicalize()
+ .map_err(|source| report(ReleaseError::UnsupportedTool, source))?;
+ if !is_executable_file(&canonical) {
+ return Err(report(
+ ReleaseError::UnsupportedTool,
+ "gh path is not an executable regular file",
+ ));
+ }
+ Ok(canonical)
+}
+
+fn check_version(stdout: &[u8], token: Option<&GithubToken>) -> Result<(), Report> {
+ let text = String::from_utf8_lossy(stdout);
+ let first = text.lines().next().unwrap_or_default();
+ let qualified = first.strip_prefix("gh version ").is_some_and(|rest| {
+ let (version, tail) = rest.split_once(' ').unwrap_or((rest, ""));
+ version == GH_VERSION && (tail.is_empty() || (tail.starts_with('(') && tail.ends_with(')')))
+ });
+ if qualified {
+ return Ok(());
+ }
+ let excerpt: String = token
+ .map_or_else(|| first.to_owned(), |token| token.redact(first))
+ .chars()
+ .take(VERSION_EXCERPT_CHARS)
+ .collect();
+ Err(report(
+ ReleaseError::UnsupportedTool,
+ format!(
+ "gh reported `{excerpt}`; release verification requires exactly gh {GH_VERSION}. \
+ This is a compatibility diagnostic: version text does not authenticate the executable"
+ ),
+ ))
+}
+
+/// A resolved, version-checked gh executable with an isolated environment.
+#[derive(Debug)]
+pub(crate) struct GhRunner {
+ executable: PathBuf,
+ environment: ChildEnvironment,
+ token: Option,
+ offline_roots: Option<(PathBuf, AssetMeasurement)>,
+ bounds: ProcessBounds,
+}
+
+impl GhRunner {
+ /// Resolves gh once, isolates its environment and checks its version.
+ ///
+ /// # Errors
+ ///
+ /// Returns [`ReleaseError::Environment`] for loader injection or a missing
+ /// explicit token, [`ReleaseError::UnsupportedTool`] when gh is missing or
+ /// not exactly the qualified version, or a process error from the
+ /// version check.
+ pub(crate) fn resolve(
+ location: GhLocation<'_>,
+ caller: &CallerEnvironment,
+ bounds: ProcessBounds,
+ deadline: AttemptDeadline,
+ cancellation: &Cancellation,
+ ) -> Result> {
+ let token = Some(GithubToken::select(caller)?);
+ let environment = ChildEnvironment::isolated(caller)?;
+ let executable = locate(location, caller)?;
+ let runner = Self {
+ executable,
+ environment,
+ token,
+ offline_roots: None,
+ bounds,
+ };
+ let output = runner.run(&[OsString::from("--version")], deadline, cancellation)?;
+ check_version(&output.stdout, runner.token.as_ref())?;
+ Ok(runner)
+ }
+
+ /// Resolves a token-free bundle verifier with independently pinned roots.
+ ///
+ /// # Errors
+ ///
+ /// Rejects unsafe environment, missing tools, changed roots or wrong pins.
+ pub(crate) fn resolve_offline(
+ location: GhLocation<'_>,
+ caller: &CallerEnvironment,
+ bounds: ProcessBounds,
+ deadline: AttemptDeadline,
+ cancellation: &Cancellation,
+ roots: &Path,
+ expected_sha256: &str,
+ ) -> Result> {
+ if !super::documents::lower_hex(expected_sha256, 64) {
+ return Err(report(
+ ReleaseError::Policy,
+ "independent roots pin must be lowercase SHA-256",
+ ));
+ }
+ let environment = ChildEnvironment::isolated(caller)?;
+ let executable = locate(location, caller)?;
+ let (snapshot, measurement) = input::snapshot(
+ roots,
+ environment.root.path(),
+ bounds::TRUSTED_ROOTS_BYTES,
+ deadline,
+ cancellation,
+ )?;
+ if measurement.sha256() != expected_sha256 {
+ return Err(report(
+ ReleaseError::Policy,
+ "trusted roots differ from independently approved digest",
+ ));
+ }
+ let runner = Self {
+ executable,
+ environment,
+ token: None,
+ offline_roots: Some((snapshot, measurement)),
+ bounds,
+ };
+ let output = runner.run(&[OsString::from("--version")], deadline, cancellation)?;
+ check_version(&output.stdout, None)?;
+ Ok(runner)
+ }
+
+ /// Replaces the process bounds; tests use short deadlines and caps while
+ /// production keeps [`ProcessBounds::production`].
+ #[cfg(test)]
+ pub(crate) fn with_bounds(mut self, bounds: ProcessBounds) -> Self {
+ self.bounds = bounds;
+ self
+ }
+
+ /// Returns the canonical executable every invocation uses.
+ pub(crate) fn executable(&self) -> &Path {
+ &self.executable
+ }
+
+ /// Runs gh with a fixed argument vector under all bounds.
+ ///
+ /// # Errors
+ ///
+ /// Returns [`ReleaseError::Timeout`], [`ReleaseError::Cancelled`],
+ /// [`ReleaseError::OutputLimit`], [`ReleaseError::DescendantEscaped`],
+ /// [`ReleaseError::Process`] or [`ReleaseError::ExitFailure`]; output from
+ /// a failed child is never returned.
+ pub(crate) fn run(
+ &self,
+ args: &[OsString],
+ deadline: AttemptDeadline,
+ cancellation: &Cancellation,
+ ) -> Result> {
+ let mut command = Command::new(&self.executable);
+ command
+ .args(args)
+ .env_clear()
+ .envs(self.environment.variables.iter().cloned())
+ .current_dir(self.environment.root.path())
+ .stdin(Stdio::null())
+ .stdout(Stdio::piped())
+ .stderr(Stdio::piped())
+ .process_group(0);
+ if let Some(token) = &self.token {
+ command.env("GH_TOKEN", token.expose());
+ }
+ let completed = supervise(&mut command, &self.bounds, deadline, cancellation)?;
+ let raw_stderr = String::from_utf8_lossy(&completed.stderr);
+ let stderr = self
+ .token
+ .as_ref()
+ .map_or_else(|| raw_stderr.to_string(), |token| token.redact(&raw_stderr));
+ if !completed.status.success() {
+ let excerpt: String = stderr.chars().take(STDERR_EXCERPT_BYTES).collect();
+ return Err(report(
+ ReleaseError::ExitFailure,
+ format!("gh {}; stderr: {excerpt}", completed.status),
+ ));
+ }
+ Ok(ChildOutput {
+ stdout: completed.stdout,
+ stderr,
+ })
+ }
+
+ /// Runs one fixed `gh attestation verify` against one retained,
+ /// membership-checked bundle and returns its single result.
+ ///
+ /// # Errors
+ ///
+ /// Returns any [`Self::run`] error, [`ReleaseError::Document`] for
+ /// malformed or incomplete JSON, or [`ReleaseError::Output`] unless stdout
+ /// is an array of exactly one attestation result.
+ pub(crate) fn verify_attestation(
+ &self,
+ subject: &Path,
+ bundle: &Path,
+ release: &ApprovedRelease,
+ predicate: PredicateKind,
+ deadline: AttemptDeadline,
+ cancellation: &Cancellation,
+ ) -> Result> {
+ attestation_verify_args(subject, bundle, release, predicate)?;
+ let private = tempfile::Builder::new()
+ .prefix("proof-")
+ .tempdir_in(self.environment.root.path())
+ .map_err(|error| report(ReleaseError::Asset, error))?;
+ let subject_limit = if subject
+ .file_name()
+ .is_some_and(|name| name == "release-index.json")
+ {
+ bounds::INDEX_BYTES
+ } else {
+ bounds::EXECUTABLE_BYTES
+ };
+ let (private_subject, subject_measurement) = input::snapshot(
+ subject,
+ private.path(),
+ subject_limit,
+ deadline,
+ cancellation,
+ )?;
+ let (private_bundle, bundle_measurement) = input::snapshot(
+ bundle,
+ private.path(),
+ bounds::BUNDLE_BYTES,
+ deadline,
+ cancellation,
+ )?;
+ let bundle_bytes =
+ std::fs::read(&private_bundle).map_err(|error| report(ReleaseError::Asset, error))?;
+ parse_json(&bundle_bytes, Role::Bundle)?;
+ let mut args =
+ attestation_verify_args(&private_subject, &private_bundle, release, predicate)?;
+ if let Some((roots, measurement)) = &self.offline_roots {
+ input::unchanged(
+ roots,
+ measurement,
+ bounds::TRUSTED_ROOTS_BYTES,
+ deadline,
+ cancellation,
+ )?;
+ args.extend([
+ OsString::from("--custom-trusted-root"),
+ roots.as_os_str().to_owned(),
+ ]);
+ }
+ let output = self.run(&args, deadline, cancellation)?;
+ input::unchanged(
+ subject,
+ &subject_measurement,
+ subject_limit,
+ deadline,
+ cancellation,
+ )?;
+ input::unchanged(
+ bundle,
+ &bundle_measurement,
+ bounds::BUNDLE_BYTES,
+ deadline,
+ cancellation,
+ )?;
+ input::unchanged(
+ &private_subject,
+ &subject_measurement,
+ subject_limit,
+ deadline,
+ cancellation,
+ )?;
+ input::unchanged(
+ &private_bundle,
+ &bundle_measurement,
+ bounds::BUNDLE_BYTES,
+ deadline,
+ cancellation,
+ )?;
+ if let Some((roots, measurement)) = &self.offline_roots {
+ input::unchanged(
+ roots,
+ measurement,
+ bounds::TRUSTED_ROOTS_BYTES,
+ deadline,
+ cancellation,
+ )?;
+ }
+ let value = parse_json(&output.stdout, Role::GhOutput)?;
+ let Value::Array(results) = value else {
+ return Err(report(
+ ReleaseError::Output,
+ "gh attestation output must be a JSON array",
+ ));
+ };
+ if results.len() != bounds::MATCHING_ATTESTATIONS_PER_BUNDLE {
+ return Err(report(
+ ReleaseError::Output,
+ format!(
+ "gh returned {} attestation results; exactly one is required",
+ results.len()
+ ),
+ ));
+ }
+ let result = results
+ .into_iter()
+ .next()
+ .ok_or_else(|| report(ReleaseError::Output, "gh returned no attestation result"))?;
+ Ok(VerifiedAttestation {
+ mode: if self.offline_roots.is_some() {
+ VerificationMode::Offline
+ } else {
+ VerificationMode::Online
+ },
+ result,
+ release: release.clone(),
+ predicate,
+ subject: subject_measurement,
+ bundle: bundle_measurement,
+ })
+ }
+
+ /// Verifies the official release with the pinned tool and bounded JSON.
+ ///
+ /// gh 2.97.0 `pkg/cmd/release/verify/verify.go` exports one processing
+ /// result object with `--format json`, unlike attestation verify's array.
+ ///
+ /// # Errors
+ ///
+ /// Returns runner or strict JSON errors, or an output error for a shape
+ /// outside the pinned contract. Publication semantics are checked later.
+ pub(crate) fn verify_release(
+ &self,
+ release: &ApprovedRelease,
+ deadline: AttemptDeadline,
+ cancellation: &Cancellation,
+ ) -> Result> {
+ if self.offline_roots.is_some() {
+ return Err(report(
+ ReleaseError::Publication,
+ "offline build evidence cannot verify publication",
+ ));
+ }
+ let output = self.run(&release_verify_args(release.tag()), deadline, cancellation)?;
+ let result = parse_json(&output.stdout, Role::GhOutput)?;
+ if !result.is_object()
+ || !result
+ .get("verificationResult")
+ .is_some_and(Value::is_object)
+ {
+ return Err(report(
+ ReleaseError::Output,
+ "gh release output must be one verification result object",
+ ));
+ }
+ Ok(VerifiedReleaseStatement {
+ result,
+ release: release.clone(),
+ })
+ }
+}
diff --git a/crates/trusted-server-cli/src/release/gh/input.rs b/crates/trusted-server-cli/src/release/gh/input.rs
new file mode 100644
index 0000000000..2bcf760d25
--- /dev/null
+++ b/crates/trusted-server-cli/src/release/gh/input.rs
@@ -0,0 +1,121 @@
+//! Bounded private snapshots supplied to the verifier child.
+
+use std::fs::{File, OpenOptions};
+use std::io::{Read as _, Write as _};
+use std::os::unix::fs::{MetadataExt as _, OpenOptionsExt as _};
+use std::path::{Path, PathBuf};
+use std::time::Instant;
+
+use error_stack::Report;
+use sha2::{Digest as _, Sha256};
+
+use crate::release::artifact::AssetMeasurement;
+use crate::release::bounds::{AttemptDeadline, Cancellation};
+use crate::release::error::{ReleaseError, report};
+
+fn checked_transfer(
+ path: &Path,
+ mut destination: Option<&mut File>,
+ limit: u64,
+ deadline: AttemptDeadline,
+ cancellation: &Cancellation,
+) -> Result> {
+ let mut input = OpenOptions::new()
+ .read(true)
+ .custom_flags(libc::O_NOFOLLOW | libc::O_NONBLOCK)
+ .open(path)
+ .map_err(|error| report(ReleaseError::Asset, error))?;
+ let metadata = input
+ .metadata()
+ .map_err(|error| report(ReleaseError::Asset, error))?;
+ if !metadata.is_file() || metadata.nlink() != 1 || metadata.len() > limit {
+ return Err(report(
+ ReleaseError::Asset,
+ "gh input is not a bounded regular file",
+ ));
+ }
+ let mut hasher = Sha256::new();
+ let mut length = 0_u64;
+ let mut buffer = [0_u8; 64 * 1024];
+ loop {
+ if cancellation.is_cancelled() {
+ return Err(report(
+ ReleaseError::Cancelled,
+ "gh input snapshot cancelled",
+ ));
+ }
+ if Instant::now() >= deadline.instant() {
+ return Err(report(
+ ReleaseError::Timeout,
+ "gh input snapshot exceeded deadline",
+ ));
+ }
+ let read = input
+ .read(&mut buffer)
+ .map_err(|error| report(ReleaseError::Asset, error))?;
+ if read == 0 {
+ break;
+ }
+ length += read as u64;
+ if length > limit {
+ return Err(report(
+ ReleaseError::Asset,
+ "gh input exceeds its role limit",
+ ));
+ }
+ let chunk = &buffer[..read];
+ hasher.update(chunk);
+ if let Some(output) = destination.as_deref_mut() {
+ output
+ .write_all(chunk)
+ .map_err(|error| report(ReleaseError::Asset, error))?;
+ }
+ }
+ Ok(AssetMeasurement::declared(
+ &hex::encode(hasher.finalize()),
+ length,
+ ))
+}
+
+/// Copies a bounded file into an attempt-private directory and measures it.
+///
+/// # Errors
+///
+/// Returns an asset, cancellation or timeout error for failed snapshots.
+pub(super) fn snapshot(
+ path: &Path,
+ directory: &Path,
+ limit: u64,
+ deadline: AttemptDeadline,
+ cancellation: &Cancellation,
+) -> Result<(PathBuf, AssetMeasurement), Report> {
+ let name = path
+ .file_name()
+ .ok_or_else(|| report(ReleaseError::Asset, "gh input has no file name"))?;
+ let private = directory.join(name);
+ let mut output =
+ File::create_new(&private).map_err(|error| report(ReleaseError::Asset, error))?;
+ let measurement = checked_transfer(path, Some(&mut output), limit, deadline, cancellation)?;
+ Ok((private, measurement))
+}
+
+/// Requires the current file to match its pre-invocation snapshot.
+///
+/// # Errors
+///
+/// Returns an asset error if a substitution or read failure occurs.
+pub(super) fn unchanged(
+ path: &Path,
+ expected: &AssetMeasurement,
+ limit: u64,
+ deadline: AttemptDeadline,
+ cancellation: &Cancellation,
+) -> Result<(), Report> {
+ if &checked_transfer(path, None, limit, deadline, cancellation)? != expected {
+ return Err(report(
+ ReleaseError::Asset,
+ "gh input changed during verification",
+ ));
+ }
+ Ok(())
+}
diff --git a/crates/trusted-server-cli/src/release/github.rs b/crates/trusted-server-cli/src/release/github.rs
new file mode 100644
index 0000000000..ddd844a86c
--- /dev/null
+++ b/crates/trusted-server-cli/src/release/github.rs
@@ -0,0 +1,477 @@
+//! R-PUBLICATION: official GitHub release, tag and release-statement checks.
+//!
+//! Requests go only to fixed official REST paths on the fixed API host with
+//! an explicit read-only credential, no redirects and bounded bodies. The
+//! checks themselves are pure functions over the fetched JSON so they are
+//! exercised without network access.
+//!
+//! Field names were confirmed from the pinned gh 2.97.0 REST models:
+//! release `tag_name`/`draft`/`prerelease`/`immutable`/`assets[].name`/
+//! `size`/`digest`/`state` in
+//! ,
+//! the tag ref `object.sha` in the same file (`object.type` is from the
+//! GitHub git refs REST documentation), and the release attestation `tag`
+//! predicate field and release-ref subject in
+//!
+//! and .
+
+use std::collections::BTreeMap;
+
+use error_stack::Report;
+use reqwest::header::{ACCEPT, AUTHORIZATION, HeaderName, HeaderValue, USER_AGENT};
+use reqwest::{Client, Method, Request, StatusCode};
+use serde_json::{Map, Value};
+use url::Url;
+
+use super::artifact::AssetMeasurement;
+use super::bounds::{self, AttemptDeadline};
+use super::documents::{ReleaseTag, Role, lower_hex, parse_json};
+use super::environment::GithubToken;
+use super::error::{ReleaseError, report};
+use super::evidence::{BuildEvidenceVerified, INDEX_NAME};
+use super::gh::{VerificationMode, VerifiedReleaseStatement};
+use super::source_policy::BoundedBody;
+
+/// Fixed official repository REST root; only the tag varies below it.
+pub(crate) const API_REPOSITORY: &str = "https://api.github.com/repos/IABTechLab/trusted-server"; // allow-domain: api.github.com
+/// Certificate SAN of GitHub's release attestation signer, enforced by gh.
+const RELEASE_SIGNER: &str = "https://dotcom.releases.github.com"; // allow-domain: dotcom.releases.github.com
+/// Name of the external index provenance bundle.
+pub(crate) const INDEX_BUNDLE_NAME: &str = "release-index.provenance.sigstore.json";
+/// Name of the convenience checksum list.
+pub(crate) const CHECKSUMS_NAME: &str = "SHA256SUMS";
+const JSON_MEDIA_TYPE: &str = "application/vnd.github+json";
+const API_VERSION: &str = "2022-11-28";
+
+fn publication_error(message: impl std::fmt::Display) -> Report {
+ report(ReleaseError::Publication, message)
+}
+
+fn rest_request(path: &str, token: &GithubToken) -> Result> {
+ let url = Url::parse(&format!("{API_REPOSITORY}/{path}"))
+ .map_err(|source| report(ReleaseError::Publication, source))?;
+ let mut request = Request::new(Method::GET, url);
+ let mut authorization = HeaderValue::from_str(&format!("Bearer {}", token.expose()))
+ .map_err(|_| publication_error("GitHub token is not a valid header value"))?;
+ authorization.set_sensitive(true);
+ let headers = request.headers_mut();
+ headers.insert(ACCEPT, HeaderValue::from_static(JSON_MEDIA_TYPE));
+ headers.insert(
+ HeaderName::from_static("x-github-api-version"),
+ HeaderValue::from_static(API_VERSION),
+ );
+ headers.insert(USER_AGENT, HeaderValue::from_static("trusted-server-cli"));
+ headers.insert(AUTHORIZATION, authorization);
+ Ok(request)
+}
+
+/// Builds the fixed release-by-tag request.
+///
+/// # Errors
+///
+/// Returns [`ReleaseError::Publication`] if the request cannot be encoded.
+pub(crate) fn release_request(
+ tag: &ReleaseTag,
+ token: &GithubToken,
+) -> Result> {
+ rest_request(&format!("releases/tags/{}", tag.as_str()), token)
+}
+
+/// Builds the fixed lightweight tag ref request.
+///
+/// # Errors
+///
+/// Returns [`ReleaseError::Publication`] if the request cannot be encoded.
+pub(crate) fn tag_ref_request(
+ tag: &ReleaseTag,
+ token: &GithubToken,
+) -> Result> {
+ rest_request(&format!("git/ref/tags/{}", tag.as_str()), token)
+}
+
+/// Executes a fixed REST request and strictly parses its bounded JSON body.
+///
+/// The client must not follow redirects (see
+/// [`super::source_policy::policy_client`]); any 3xx, non-200 status, final
+/// URL change or body over 1 MiB fails.
+///
+/// # Errors
+///
+/// Returns [`ReleaseError::Publication`] for transport, status, origin or
+/// size failures and [`ReleaseError::Timeout`] after the attempt deadline.
+pub(crate) async fn fetch_json(
+ client: &Client,
+ request: Request,
+ token: &GithubToken,
+ deadline: AttemptDeadline,
+) -> Result> {
+ let request_url = request.url().clone();
+ let fetch = async {
+ let mut response = client
+ .execute(request)
+ .await
+ .map_err(|error| publication_error(token.redact(&error.to_string())))?;
+ if response.status() != StatusCode::OK || response.url() != &request_url {
+ return Err(publication_error(format!(
+ "official REST endpoint returned {} (redirects are rejected)",
+ response.status()
+ )));
+ }
+ let mut body = BoundedBody::new(bounds::METADATA_BYTES);
+ while let Some(chunk) = response
+ .chunk()
+ .await
+ .map_err(|error| publication_error(token.redact(&error.to_string())))?
+ {
+ body.push(&chunk).map_err(|error| {
+ error
+ .change_context(ReleaseError::Publication)
+ .attach("REST response exceeds 1 MiB")
+ })?;
+ }
+ parse_json(&body.into_bytes(), Role::Metadata)
+ };
+ tokio::time::timeout_at(tokio::time::Instant::from_std(deadline.instant()), fetch)
+ .await
+ .map_err(|_| report(ReleaseError::Timeout, "REST request exceeded the deadline"))?
+}
+
+/// Every asset the official release must contain, with exact length and
+/// SHA-256.
+fn expected_assets(
+ build: &BuildEvidenceVerified,
+ index_bundle: &AssetMeasurement,
+ checksums: &AssetMeasurement,
+) -> BTreeMap {
+ let index = build.index();
+ let mut assets = BTreeMap::new();
+ let mut add = |name: &str, size: u64, sha256: &str| {
+ assets.insert(name.to_owned(), AssetMeasurement::declared(sha256, size));
+ };
+ add(
+ &index.tsjs_inventory.name,
+ index.tsjs_inventory.size,
+ &index.tsjs_inventory.sha256,
+ );
+ for artifact in &index.artifacts {
+ add(&artifact.name, artifact.size, &artifact.sha256);
+ let companions = &artifact.companions;
+ for record in [
+ &companions.sbom,
+ &companions.provenance,
+ &companions.sbom_attestation,
+ &companions.build_evidence,
+ &companions.build_recipe,
+ &companions.rebuild_check,
+ ] {
+ add(&record.name, record.size, &record.sha256);
+ }
+ }
+ let measured = [
+ (INDEX_NAME, build.index_measurement()),
+ (INDEX_BUNDLE_NAME, index_bundle),
+ (CHECKSUMS_NAME, checksums),
+ ];
+ for (name, measurement) in measured {
+ add(name, measurement.length(), measurement.sha256());
+ }
+ assets
+}
+
+fn is_prerelease(tag: &ReleaseTag) -> bool {
+ let version = tag.as_str().split('+').next().unwrap_or_default();
+ version.contains('-')
+}
+
+/// Checks the release-by-tag REST object against the expected asset set.
+///
+/// # Errors
+///
+/// Returns [`ReleaseError::Publication`] unless the release is the published,
+/// immutable release of `tag` with a prerelease flag matching the tag and
+/// exactly the expected assets, each uploaded with the expected REST size
+/// (and digest, when GitHub reports one).
+pub(crate) fn check_release(
+ release: &Value,
+ tag: &ReleaseTag,
+ expected: &BTreeMap,
+) -> Result<(), Report> {
+ if release.get("tag_name").and_then(Value::as_str) != Some(tag.as_str()) {
+ return Err(publication_error("REST release is for another tag"));
+ }
+ if release.get("draft") != Some(&Value::Bool(false)) {
+ return Err(publication_error("release is a draft, not published"));
+ }
+ if release.get("prerelease") != Some(&Value::Bool(is_prerelease(tag))) {
+ return Err(publication_error(
+ "release prerelease flag does not match the tag",
+ ));
+ }
+ if release.get("immutable") != Some(&Value::Bool(true)) {
+ return Err(publication_error("release is not immutable"));
+ }
+ let listed = release_assets(release)?;
+ if listed.len() != expected.len() || listed.keys().ne(expected.keys()) {
+ return Err(publication_error(
+ "release assets are not exactly the signed release asset set",
+ ));
+ }
+ for (name, (size, digest)) in &listed {
+ let wanted = expected
+ .get(*name)
+ .ok_or_else(|| publication_error(format!("unexpected release asset `{name}`")))?;
+ if *size != wanted.length() {
+ return Err(publication_error(format!(
+ "REST size of `{name}` differs from the signed and actual length"
+ )));
+ }
+ if digest.is_some_and(|digest| digest != format!("sha256:{}", wanted.sha256())) {
+ return Err(publication_error(format!(
+ "REST digest of `{name}` differs from the signed digest"
+ )));
+ }
+ }
+ Ok(())
+}
+
+type ListedAssets<'a> = BTreeMap<&'a str, (u64, Option<&'a str>)>;
+
+fn release_assets(release: &Value) -> Result, Report> {
+ let assets = release
+ .get("assets")
+ .and_then(Value::as_array)
+ .ok_or_else(|| publication_error("REST release has no asset list"))?;
+ let mut listed = BTreeMap::new();
+ for asset in assets {
+ let name = asset.get("name").and_then(Value::as_str);
+ let size = asset.get("size").and_then(Value::as_u64);
+ let (Some(name), Some(size)) = (name, size) else {
+ return Err(publication_error("REST asset lacks a name or size"));
+ };
+ if asset.get("state").and_then(Value::as_str) != Some("uploaded") {
+ return Err(publication_error(format!(
+ "release asset `{name}` is not fully uploaded"
+ )));
+ }
+ let digest = asset.get("digest").and_then(Value::as_str);
+ if listed.insert(name, (size, digest)).is_some() {
+ return Err(publication_error(format!(
+ "release lists asset `{name}` twice"
+ )));
+ }
+ }
+ Ok(listed)
+}
+
+/// Checks that the tag is lightweight and targets the approved commit.
+///
+/// # Errors
+///
+/// Returns [`ReleaseError::Publication`] for another ref, an annotated tag
+/// object or another commit.
+pub(crate) fn check_tag_ref(
+ tag_ref: &Value,
+ tag: &ReleaseTag,
+ commit: &str,
+) -> Result<(), Report> {
+ if tag_ref.get("ref").and_then(Value::as_str) != Some(&format!("refs/tags/{}", tag.as_str())) {
+ return Err(publication_error("REST ref is not the release tag"));
+ }
+ let object = tag_ref.get("object");
+ if object
+ .and_then(|object| object.get("type"))
+ .and_then(Value::as_str)
+ != Some("commit")
+ {
+ return Err(publication_error(
+ "release tag must be lightweight; annotated tag objects are rejected",
+ ));
+ }
+ if object
+ .and_then(|object| object.get("sha"))
+ .and_then(Value::as_str)
+ != Some(commit)
+ {
+ return Err(publication_error(
+ "release tag does not target the approved commit",
+ ));
+ }
+ Ok(())
+}
+
+/// Checks the verified GitHub release attestation (`gh release verify
+/// --format json`) binds the tag, approved commit and every asset digest.
+///
+/// # Errors
+///
+/// Returns [`ReleaseError::Publication`] for another signer, tag or commit,
+/// or signed asset names/digests that differ from the expected set.
+pub(crate) fn check_release_statement(
+ verified: &Value,
+ tag: &ReleaseTag,
+ commit: &str,
+ expected: &BTreeMap,
+) -> Result<(), Report> {
+ let result = verified.get("verificationResult");
+ let field = |path: &[&str]| {
+ path.iter()
+ .try_fold(result?, |value, key| value.get(key))
+ .and_then(Value::as_str)
+ };
+ if field(&["mediaType"])
+ != Some("application/vnd.dev.sigstore.verificationresult+json;version=0.1")
+ || field(&["statement", "_type"]) != Some("https://in-toto.io/Statement/v1")
+ || field(&["statement", "predicateType"])
+ != Some("https://in-toto.io/attestation/release/v0.1")
+ {
+ return Err(publication_error(
+ "unsupported verified release statement type",
+ ));
+ }
+ let purl = format!("pkg:github/{}@{}", super::policy::REPOSITORY, tag.as_str());
+ for (key, wanted) in [
+ ("repository", super::policy::REPOSITORY),
+ ("repositoryId", super::policy::REPOSITORY_ID),
+ ("ownerId", super::policy::REPOSITORY_OWNER_ID),
+ ("purl", purl.as_str()),
+ ] {
+ if field(&["statement", "predicate", key]) != Some(wanted) {
+ return Err(publication_error(format!(
+ "signed release `{key}` differs from the official repository"
+ )));
+ }
+ }
+ if field(&["signature", "certificate", "subjectAlternativeName"]) != Some(RELEASE_SIGNER) {
+ return Err(publication_error("release statement has another signer"));
+ }
+ if field(&["statement", "predicate", "tag"]) != Some(tag.as_str()) {
+ return Err(publication_error("release statement names another tag"));
+ }
+ let subjects = result
+ .and_then(|result| result.get("statement"))
+ .and_then(|statement| statement.get("subject"))
+ .and_then(Value::as_array)
+ .ok_or_else(|| publication_error("release statement has no subjects"))?;
+ let (named, commits) = release_subjects(subjects, &purl)?;
+ if commits != [commit] {
+ return Err(publication_error(
+ "release statement does not bind exactly the approved commit",
+ ));
+ }
+ let wanted: BTreeMap<&str, &str> = expected
+ .iter()
+ .map(|(name, measurement)| (name.as_str(), measurement.sha256()))
+ .collect();
+ if named != wanted {
+ return Err(publication_error(
+ "signed release asset names or digests differ from the verified assets",
+ ));
+ }
+ Ok(())
+}
+
+type ReleaseSubjects<'a> = (BTreeMap<&'a str, &'a str>, Vec<&'a str>);
+
+fn release_subjects<'a>(
+ subjects: &'a [Value],
+ purl: &str,
+) -> Result, Report> {
+ let mut named = BTreeMap::new();
+ let mut commits = Vec::new();
+ for subject in subjects {
+ let digest = subject.get("digest").and_then(Value::as_object);
+ let name = subject
+ .get("name")
+ .and_then(Value::as_str)
+ .unwrap_or_default();
+ if name.is_empty() {
+ if subject.as_object().map(Map::len) != Some(2)
+ || subject.get("uri").and_then(Value::as_str) != Some(purl)
+ || digest.map(Map::len) != Some(1)
+ {
+ return Err(publication_error(
+ "release commit subject must have exactly the official URI and SHA-1",
+ ));
+ }
+ let sha1 = digest
+ .and_then(|digest| digest.get("sha1"))
+ .and_then(Value::as_str);
+ commits.push(sha1.unwrap_or_default());
+ continue;
+ }
+ if subject.as_object().map(Map::len) != Some(2) {
+ return Err(publication_error(
+ "release asset subject must have exactly name and digest",
+ ));
+ }
+ let sha256 = digest
+ .filter(|digest| digest.len() == 1)
+ .and_then(|digest| digest.get("sha256"))
+ .and_then(Value::as_str)
+ .filter(|sha256| lower_hex(sha256, 64))
+ .ok_or_else(|| publication_error("signed release asset lacks one SHA-256"))?;
+ if named.insert(name, sha256).is_some() {
+ return Err(publication_error(format!(
+ "release statement names `{name}` twice"
+ )));
+ }
+ }
+ Ok((named, commits))
+}
+
+/// Fetched official publication data and release-wide asset measurements.
+#[derive(Clone, Copy, Debug)]
+pub(crate) struct PublicationInputs<'a> {
+ pub(crate) release: &'a Value,
+ pub(crate) tag_ref: &'a Value,
+ pub(crate) release_statement: &'a VerifiedReleaseStatement,
+ pub(crate) index_bundle: &'a AssetMeasurement,
+ pub(crate) checksums: &'a AssetMeasurement,
+}
+
+/// Build evidence that is also the official immutable publication.
+///
+/// Only [`verify_publication`] constructs it.
+#[derive(Debug)]
+pub(crate) struct PublicationVerified {
+ build: BuildEvidenceVerified,
+}
+
+impl PublicationVerified {
+ /// Returns the verified build evidence.
+ pub(crate) fn build(&self) -> &BuildEvidenceVerified {
+ &self.build
+ }
+}
+
+/// Verifies the official immutable publication of verified build evidence.
+///
+/// # Errors
+///
+/// Returns [`ReleaseError::Publication`] if the tag is annotated or targets
+/// another commit, the release is not the immutable published release with
+/// exactly the expected assets and REST lengths, or the release statement
+/// does not bind every asset name and digest.
+pub(crate) fn verify_publication(
+ build: BuildEvidenceVerified,
+ inputs: &PublicationInputs<'_>,
+) -> Result> {
+ if build.mode() != VerificationMode::Online {
+ return Err(publication_error(
+ "offline build evidence cannot become official publication evidence",
+ ));
+ }
+ let tag = build.release().tag().clone();
+ let commit = build.release().commit().as_str().to_owned();
+ check_tag_ref(inputs.tag_ref, &tag, &commit)?;
+ let expected = expected_assets(&build, inputs.index_bundle, inputs.checksums);
+ check_release(inputs.release, &tag, &expected)?;
+ if inputs.index_bundle != build.index_bundle() {
+ return Err(publication_error(
+ "published index bundle differs from the verified bundle",
+ ));
+ }
+ let statement = inputs.release_statement.for_release(build.release())?;
+ check_release_statement(statement, &tag, &commit, &expected)?;
+ Ok(PublicationVerified { build })
+}
diff --git a/crates/trusted-server-cli/src/release/mod.rs b/crates/trusted-server-cli/src/release/mod.rs
new file mode 100644
index 0000000000..8e66f491f5
--- /dev/null
+++ b/crates/trusted-server-cli/src/release/mod.rs
@@ -0,0 +1,26 @@
+//! Consumer-side verification of attested Trusted Server releases.
+//!
+//! Everything here is private to the CLI. The embedded consumer policy and the
+//! compiled constants in [`policy`] are the only verifier authority; downloaded
+//! release metadata, operator configuration and forwarded gh flags never
+//! redefine them.
+
+mod artifact;
+mod bounds;
+mod documents;
+mod environment;
+mod error;
+mod evidence;
+mod gh;
+mod github;
+mod policy;
+mod process;
+mod source_policy;
+
+#[cfg(test)]
+mod tests;
+
+mod offline;
+mod online;
+
+pub(crate) use online::verify;
diff --git a/crates/trusted-server-cli/src/release/offline.rs b/crates/trusted-server-cli/src/release/offline.rs
new file mode 100644
index 0000000000..826fa1db56
--- /dev/null
+++ b/crates/trusted-server-cli/src/release/offline.rs
@@ -0,0 +1,191 @@
+//! Private build-evidence-only verification of securely transferred inputs.
+//!
+//! The caller supplies independently approved source, artifact and root pins.
+//! No publication proof, report or deployment authorization is produced.
+
+use std::fs::{File, OpenOptions};
+use std::io::Read as _;
+use std::os::unix::fs::OpenOptionsExt as _;
+use std::path::Path;
+use std::time::Instant;
+
+use error_stack::Report;
+use serde_json::Value;
+
+use super::artifact::{AssetRole, RetainedAsset, RetainedDownload};
+use super::bounds::{AttemptDeadline, Cancellation, ProcessBounds};
+use super::documents::{ApprovedRelease, lower_hex, safe_name};
+use super::environment::CallerEnvironment;
+use super::error::{ReleaseError, report};
+use super::evidence::BuildEvidenceVerified;
+use super::gh::{GhLocation, GhRunner};
+use super::online::{Transport, build_with};
+use super::source_policy::{ApprovedSourcePolicy, ApprovedSourceRoot, read_approved_snapshot};
+
+/// Independent trust inputs, transferred separately from untrusted evidence.
+pub(super) struct OfflineApproval<'a> {
+ pub(super) release: &'a ApprovedRelease,
+ pub(super) source: &'a ApprovedSourceRoot,
+ pub(super) asset: &'a str,
+ pub(super) artifact_sha256: &'a str,
+ pub(super) roots: &'a Path,
+ pub(super) roots_sha256: &'a str,
+ pub(super) gh: &'a Path,
+}
+
+/// Offline evidence deliberately hides its build proof from publication APIs.
+pub(super) struct OfflineBuildEvidence {
+ _build: BuildEvidenceVerified,
+ _asset: RetainedAsset,
+}
+
+struct LocalTransport<'a> {
+ directory: &'a Path,
+ source: &'a ApprovedSourceRoot,
+ cancellation: &'a Cancellation,
+}
+
+fn regular_file(path: &Path) -> Result> {
+ let file = OpenOptions::new()
+ .read(true)
+ .custom_flags(libc::O_NOFOLLOW | libc::O_NONBLOCK)
+ .open(path)
+ .map_err(|error| report(ReleaseError::Asset, error))?;
+ if !file
+ .metadata()
+ .map_err(|error| report(ReleaseError::Asset, error))?
+ .is_file()
+ {
+ return Err(report(
+ ReleaseError::Asset,
+ "offline input must be a regular file",
+ ));
+ }
+ Ok(file)
+}
+
+impl Transport for LocalTransport<'_> {
+ async fn policy(
+ &self,
+ deadline: AttemptDeadline,
+ cancellation: &Cancellation,
+ ) -> Result> {
+ super::artifact::check_attempt(deadline, cancellation)?;
+ read_approved_snapshot(self.source)
+ }
+
+ async fn asset(
+ &self,
+ name: &str,
+ role: AssetRole,
+ deadline: AttemptDeadline,
+ cancellation: &Cancellation,
+ ) -> Result> {
+ super::artifact::check_attempt(deadline, cancellation)?;
+ if !safe_name(name) {
+ return Err(report(ReleaseError::Asset, "unsafe offline input name"));
+ }
+ let mut file = regular_file(&self.directory.join(name))?;
+ let mut retained = RetainedDownload::start(name, role)?;
+ let mut buffer = [0_u8; 64 * 1024];
+ loop {
+ if self.cancellation.is_cancelled() {
+ return Err(report(ReleaseError::Cancelled, "offline input cancelled"));
+ }
+ if Instant::now() >= deadline.instant() {
+ return Err(report(
+ ReleaseError::Timeout,
+ "offline input deadline elapsed",
+ ));
+ }
+ let read = file
+ .read(&mut buffer)
+ .map_err(|error| report(ReleaseError::Asset, error))?;
+ if read == 0 {
+ return retained.finish();
+ }
+ retained.push(&buffer[..read])?;
+ }
+ }
+
+ async fn publication(
+ &self,
+ _tag_ref: bool,
+ deadline: AttemptDeadline,
+ cancellation: &Cancellation,
+ ) -> Result> {
+ super::artifact::check_attempt(deadline, cancellation)?;
+ Err(report(
+ ReleaseError::Publication,
+ "offline verification cannot acquire publication evidence",
+ ))
+ }
+}
+
+/// Verifies local build evidence with independently pinned source, roots and bytes.
+///
+/// The caller must enforce OS network denial during this operation. The gh
+/// invocation uses local bundles and custom roots, with no token or cache.
+///
+/// # Errors
+///
+/// Rejects any changed trust input, artifact pin or evidence graph edge.
+pub(super) async fn verify_build(
+ approval: &OfflineApproval<'_>,
+ evidence_directory: &Path,
+ caller: &CallerEnvironment,
+ deadline: AttemptDeadline,
+ cancellation: &Cancellation,
+) -> Result> {
+ if !lower_hex(approval.artifact_sha256, 64)
+ || approval.source.commit() != approval.release.commit()
+ {
+ return Err(report(
+ ReleaseError::Policy,
+ "offline source and artifact pins must be independently approved",
+ ));
+ }
+ if super::policy::embedded_policy()?
+ .artifact(approval.asset)
+ .is_none()
+ {
+ return Err(report(
+ ReleaseError::Policy,
+ "offline asset is outside the embedded cohort",
+ ));
+ }
+ let runner = GhRunner::resolve_offline(
+ GhLocation::Explicit(approval.gh),
+ caller,
+ ProcessBounds::production(),
+ deadline,
+ cancellation,
+ approval.roots,
+ approval.roots_sha256,
+ )?;
+ let transport = LocalTransport {
+ directory: evidence_directory,
+ source: approval.source,
+ cancellation,
+ };
+ let built = build_with(
+ &transport,
+ &runner,
+ approval.release,
+ approval.asset,
+ deadline,
+ cancellation,
+ )
+ .await?;
+ if built.executable.measurement().sha256() != approval.artifact_sha256 {
+ return Err(report(
+ ReleaseError::Evidence,
+ "offline executable differs from independently approved digest",
+ ));
+ }
+ super::artifact::check_attempt(deadline, cancellation)?;
+ Ok(OfflineBuildEvidence {
+ _build: built.build,
+ _asset: built.executable,
+ })
+}
diff --git a/crates/trusted-server-cli/src/release/online.rs b/crates/trusted-server-cli/src/release/online.rs
new file mode 100644
index 0000000000..d34b4e1e4f
--- /dev/null
+++ b/crates/trusted-server-cli/src/release/online.rs
@@ -0,0 +1,554 @@
+//! Fixed-origin online orchestration of the private consumer verifier.
+
+use std::future::Future;
+
+use error_stack::Report;
+use reqwest::{Client, StatusCode};
+use serde_json::Value;
+use url::Url;
+
+use super::artifact::{
+ AssetRole, RetainedAsset, RetainedDownload, VerificationReport, VerifiedReleaseAsset,
+ stream_into,
+};
+use super::bounds::{AttemptDeadline, Cancellation, ProcessBounds};
+use super::documents::{ApprovedCommit, ApprovedRelease, ReleaseTag, safe_name};
+use super::environment::{CallerEnvironment, GithubToken};
+use super::error::{ReleaseError, report};
+use super::evidence::{
+ BuildEvidenceVerified, ConsumedArtifact, EvidenceInputs, INDEX_NAME, verify_build_evidence,
+ verify_index,
+};
+use super::gh::{GhLocation, GhRunner, PredicateKind};
+use super::github::{self, PublicationInputs};
+use super::policy::embedded_policy;
+use super::source_policy::{self, ApprovedSourcePolicy};
+
+/// Validates all public inputs before any environment or network access.
+pub(super) fn validate_request(
+ tag: &str,
+ asset: &str,
+ commit: &str,
+) -> Result> {
+ let tag = ReleaseTag::parse(tag)?;
+ let commit = ApprovedCommit::parse(commit)?;
+ if embedded_policy()?.artifact(asset).is_none() {
+ return Err(report(
+ ReleaseError::Policy,
+ "asset is outside the embedded release cohort",
+ ));
+ }
+ Ok(ApprovedRelease::new(tag, commit))
+}
+
+/// Constructs only canonical public release URLs; no credential is attached.
+pub(super) fn approved_asset_url(
+ release: &ApprovedRelease,
+ name: &str,
+) -> Result> {
+ if !safe_name(name) {
+ return Err(report(ReleaseError::Asset, "release asset name is unsafe"));
+ }
+ Url::parse(&format!(
+ "https://github.com/{}/releases/download/{}/{name}",
+ super::policy::REPOSITORY,
+ release.tag().as_str()
+ ))
+ .map_err(|_| report(ReleaseError::Asset, "cannot encode official asset URL"))
+}
+
+/// Exact CDN listed by GitHub's runner networking reference under release assets.
+///
+///
+pub(super) fn allowed_redirect(url: &Url) -> bool {
+ url.scheme() == "https"
+ && url.host_str() == Some("release-assets.githubusercontent.com")
+ && url.port_or_known_default() == Some(443)
+ && url.username().is_empty()
+ && url.password().is_none()
+ && url.fragment().is_none()
+}
+
+/// Internal transport seam; public dispatch always constructs [`OfficialTransport`].
+pub(super) trait Transport {
+ async fn policy(
+ &self,
+ deadline: AttemptDeadline,
+ cancellation: &Cancellation,
+ ) -> Result>;
+ async fn asset(
+ &self,
+ name: &str,
+ role: AssetRole,
+ deadline: AttemptDeadline,
+ cancellation: &Cancellation,
+ ) -> Result>;
+ async fn publication(
+ &self,
+ tag_ref: bool,
+ deadline: AttemptDeadline,
+ cancellation: &Cancellation,
+ ) -> Result>;
+}
+
+/// Interrupts a pending HTTP send or body read when its attempt is cancelled.
+pub(super) async fn cancellable(
+ future: impl Future