Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/scripts/ci_changes.py
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,7 @@
"registry-coordinator-client-node",
"registry-coordinator-client-py",
"registry-thunderid-tooling",
"registry-rstack",
"registry-cli-docs",
"registry-cli-reference",
"registry-language-server",
Expand Down
3 changes: 3 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -1778,6 +1778,9 @@ jobs:
- name: Test release candidate manifest and promotion verifier
run: python3 -m unittest release/scripts/test_release_candidate.py

- name: Test rstack release component inventory
run: python3 -m unittest release/scripts/test_rstack_components.py

- name: Test public release verifier
run: python3 -m unittest release/scripts/test_verify_public_release.py

Expand Down
79 changes: 78 additions & 1 deletion .github/workflows/release-candidate.yml
Original file line number Diff line number Diff line change
Expand Up @@ -624,6 +624,13 @@ jobs:
-p registry-evidence -p registry-evidencectl \
-p registry-evidence-oid4vci \
--target "${{ matrix.target }}"
rstack_in_release="$(python3 release/scripts/release_roster.py \
rstack-in-release "${{ needs.validate.outputs.version }}")"
if [[ "${rstack_in_release}" == true ]]; then
cargo build --release --locked \
-p registry-rstack --bin rstack \
--target "${{ matrix.target }}"
fi
IFS=. read -r release_major release_minor _release_patch \
<<<"${{ needs.validate.outputs.version }}"
include_breg=0
Expand Down Expand Up @@ -664,6 +671,13 @@ jobs:
--target "${{ matrix.target }}"
fi
mkdir -p platform
if [[ "${rstack_in_release}" == true ]]; then
asset="rstack-${{ needs.validate.outputs.tag }}-${{ matrix.asset }}"
cp "target/${{ matrix.target }}/release/rstack" "platform/${asset}"
chmod 0755 "platform/${asset}"
test "$("platform/${asset}" --version)" = \
"rstack ${{ needs.validate.outputs.version }}"
fi
for evidence_binary in evidence evidencectl evidence-oid4vci; do
asset="${evidence_binary}-${{ needs.validate.outputs.tag }}-${{ matrix.asset }}"
cp "target/${{ matrix.target }}/release/${evidence_binary}" \
Expand Down Expand Up @@ -2042,6 +2056,24 @@ jobs:
"${binary} ${{ needs.validate.outputs.version }}"
done
rm candidate/bundle-root/SHA256SUMS
if [[ "$(python3 release/scripts/release_roster.py \
rstack-in-release "${version}")" == true ]]; then
rstack_installer="rstack-${{ needs.validate.outputs.tag }}-install.sh"
awk -v version="${{ needs.validate.outputs.tag }}" '
$0 == "default_version=\"\"" {
print "default_version=\"" version "\""
rendered = 1
next
}
{ print }
END { if (!rendered) exit 1 }
' crates/registry-rstack/install.sh \
> "candidate/bundle-root/${rstack_installer}"
chmod 0755 "candidate/bundle-root/${rstack_installer}"
cp "candidate/bundle-root/${rstack_installer}" \
candidate/bundle-root/rstack-install.sh
chmod 0755 candidate/bundle-root/rstack-install.sh
fi
for name in ${RELEASE_IMAGE_NAMES}; do
candidate_ref="$(tr -d '\n' < "${canonical}/dist/images/${name}.digest")"
digest="${candidate_ref##*@}"
Expand All @@ -2067,6 +2099,7 @@ jobs:
set -euo pipefail
canonical="inputs/candidate-canonical-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}"
tag="${{ needs.validate.outputs.tag }}"
version="${{ needs.validate.outputs.version }}"
bundle="candidate/registry-stack-${tag}-candidate.tar.gz"
created_at="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
expires_at="$(date -u -d "${created_at} + 7 days" +%Y-%m-%dT%H:%M:%SZ)"
Expand Down Expand Up @@ -2097,6 +2130,12 @@ jobs:
'{name:$name,kind:$kind,size:$size,sha256:$sha256}'
done | jq -s .
)"
components=null
if [[ "$(python3 release/scripts/release_roster.py \
rstack-in-release "${version}")" == true ]]; then
components="$(printf '%s' "${payloads}" \
| python3 release/scripts/rstack_components.py --version "${version}")"
fi
images="$(
for name in ${RELEASE_IMAGE_NAMES}; do
candidate_ref="$(tr -d '\n' < "${canonical}/dist/images/${name}.digest")"
Expand Down Expand Up @@ -2134,6 +2173,7 @@ jobs:
--arg created_at "${created_at}" \
--arg expires_at "${expires_at}" \
--argjson payloads "${payloads}" \
--argjson components "${components}" \
--argjson images "${images}" \
--arg docs_name "${docs_name}" \
--arg docs_sha "$(sha256sum "candidate/bundle-root/${docs_name}" | awk '{print $1}')" \
Expand Down Expand Up @@ -2179,11 +2219,48 @@ jobs:
size: $bundle_size,
sha256: $bundle_sha
}
}' > candidate/candidate-manifest.draft.json
} + (if $components == null then {} else {components:$components} end)' \
> candidate/candidate-manifest.draft.json
python3 release/scripts/release_candidate.py seal-candidate \
--draft candidate/candidate-manifest.draft.json \
--output candidate/release-candidate-manifest.json

- name: Smoke release-pinned rstack installation from verified local assets
if: ${{ needs.validate.outputs.version != '' }}
shell: bash
run: |
set -euo pipefail
version="${{ needs.validate.outputs.version }}"
if [[ "$(python3 release/scripts/release_roster.py \
rstack-in-release "${version}")" != true ]]; then
exit 0
fi
tag="${{ needs.validate.outputs.tag }}"
assets="${GITHUB_WORKSPACE}/candidate/bundle-root"
cp candidate/release-candidate-manifest.json \
"${assets}/registry-stack-${tag}-release-manifest.json"
(
cd "${assets}"
sha256sum -- * > SHA256SUMS
)
destination="${RUNNER_TEMP}/rstack-install-smoke"
bash "${assets}/rstack-${tag}-install.sh" \
--asset-dir "${assets}" \
--bin-dir "${destination}/bin" \
--install-root "${destination}/lib" \
breg evidence
test -L "${destination}/bin/rstack"
test "$(find "${destination}/bin" -mindepth 1 -maxdepth 1 | wc -l)" -eq 1
test "$("${destination}/bin/rstack" --version)" = "rstack ${version}"
"${destination}/bin/rstack" components list --format json \
| jq -e '.components | any(.name == "breg" and .installed == true)' \
>/dev/null
"${destination}/bin/rstack" components list --format json \
| jq -e '.components | any(.name == "evidence" and .installed == true)' \
>/dev/null
test "$("${destination}/bin/rstack" breg --version)" = "bregctl ${version}"
test "$("${destination}/bin/rstack" evidence --version)" = "evidencectl ${version}"

- name: Upload one candidate manifest and bundle
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
Expand Down
20 changes: 20 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 3 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ members = [
"crates/registry-thunderid-tooling",
"crates/registry-manifest-core",
"crates/registry-manifest-cli",
"crates/registry-rstack",
Comment thread
jeremi marked this conversation as resolved.
Resolved
"crates/registry-record",
"crates/registry-render",
"crates/registry-breg-client",
Expand Down Expand Up @@ -118,6 +119,8 @@ registry-evidencectl = { path = "crates/registry-evidencectl", version = "0.44.0
registry-language-server = { path = "crates/registry-language-server", version = "0.44.0" }
registry-linkml = { path = "crates/registry-linkml", version = "0.44.0" }
registry-manifest-core = { path = "crates/registry-manifest-core", version = "0.44.0" }
registry-manifest-cli = { path = "crates/registry-manifest-cli", version = "0.44.0" }
registry-rstack = { path = "crates/registry-rstack", version = "0.44.0" }
registry-record = { path = "crates/registry-record", version = "0.44.0" }
registry-render = { path = "crates/registry-render", version = "0.44.0" }
registry-breg-client = { path = "crates/registry-breg-client", version = "0.44.0" }
Expand Down
2 changes: 1 addition & 1 deletion crates/CLIENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ or unify distinct authority contracts. Shared HTTP/token primitives live in
with the attempt rolled back), and never after a 4xx status line:
`registry_platform_httputil::client::retry_keyed_mutation` owns its loop and
waits, the BReg, Casework, Coordinator, Messaging, and Scheduling clients
expose the count as `with_max_mutation_retries` (2 by default, at most 2,
expose the count as `with_maximum_mutation_retries` (2 by default, at most 2,
0 disables), and the resend repeats the caller's exact key, headers, and
body. The other
clients never resend. Reads and unkeyed operations are never resent, and
Expand Down
10 changes: 9 additions & 1 deletion crates/registry-breg-client-node/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -114,11 +114,19 @@ Fixed-scale decimals remain JSON strings. Null and absent members remain distinc
Do not round unsupported input to make it pass. JSON whitespace and equivalent
number spellings may be canonicalized; the promise concerns values and types.

Client configuration uses `maximumResponseBytes`; the retired
`maxResponseBytes` spelling is refused. Optional configuration values are
omitted rather than set to `null`. `trustedRootCertificates` accepts PEM text,
and the supplied bundle keeps the Rust client's explicit trust-root semantics.
`attemptTimeoutMilliseconds` bounds one outbound attempt, including its body,
and `connectTimeoutMilliseconds` bounds connection setup.

Actions retain their original opaque authority and exact retry semantics.
A keyed mutation (create, patch, attachment upload or delete, Proposal
transition, immediate Action, batch, tombstone, statistics publish or withdraw)
whose outcome is unknown is resent identically under the same key up to
`maxMutationRetries` times: 0 to 2, default 2, and 0 sends it once. The
`maximumMutationRetries` additional times: 0 to 2, default 2, and 0 sends it
once, while the default permits up to three total attempts. The
outcome is unknown after a timeout or broken exchange once the request was
sent, an unusable answer, or a 5xx other than `action.handler_failed` and
`statistical_dataset.domain_violation`, which the engine returns only with the
Expand Down
45 changes: 38 additions & 7 deletions crates/registry-breg-client-node/__test__/binding.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -146,22 +146,36 @@ test('nullable record options use request defaults', async () => {
});

test('constructors reject unsupported fields and unsafe integers', () => {
assert.ok(new BaseRegistryClient({
baseUrl,
attemptTimeoutMilliseconds: 5_000,
connectTimeoutMilliseconds: 2_000,
maximumMutationRetries: 1,
}));
assert.throws(() => new BaseRegistryClient({ baseUrl, retry: true }), (error) => (
error instanceof BaseRegistryClientError && error.kind === 'configuration'
));
assert.throws(() => new BaseRegistryClient({
baseUrl,
maxResponseBytes: Number.MAX_SAFE_INTEGER + 1,
maximumResponseBytes: Number.MAX_SAFE_INTEGER + 1,
}), (error) => error instanceof BaseRegistryClientError && error.kind === 'configuration');
assert.throws(() => new BaseRegistryClient({
baseUrl,
maxResponseBytes: 8 * 1024 * 1024,
}), (error) => error instanceof BaseRegistryClientError && error.kind === 'configuration');
});

test('a configuration value the sanitizer refuses is a configuration error', () => {
for (const [label, config] of [
['an unsafe integer', { baseUrl, requestTimeoutMilliseconds: Number.MAX_SAFE_INTEGER + 1 }],
['a non-finite number', { baseUrl, maxResponseBytes: Infinity }],
['an unsafe integer', { baseUrl, attemptTimeoutMilliseconds: Number.MAX_SAFE_INTEGER + 1 }],
['a negative number', { baseUrl, maximumResponseBytes: -1 }],
['a fractional number', { baseUrl, maximumResponseBytes: 1.5 }],
['a non-finite number', { baseUrl, maximumResponseBytes: Infinity }],
['a null member', { baseUrl, maximumResponseBytes: null }],
['a binary trust bundle', { baseUrl, trustedRootCertificates: Buffer.from('not PEM text') }],
['an undefined member', { baseUrl, userAgent: undefined }],
['a function', { baseUrl, userAgent: () => 'breg-test' }],
['a date', { baseUrl, requestTimeoutMilliseconds: new Date(0) }],
['a date', { baseUrl, attemptTimeoutMilliseconds: new Date(0) }],
['a proxy', new Proxy({ baseUrl }, {})],
['no configuration', undefined],
]) {
Expand All @@ -174,7 +188,7 @@ test('a configuration value the sanitizer refuses is a configuration error', ()
}
});

test('nullable private-key JWT durations use provider defaults', () => {
test('omitted private-key JWT durations use provider defaults and null is refused', () => {
const { privateKey } = crypto.generateKeyPairSync('ec', { namedCurve: 'prime256v1' });
const clientKey = privateKey.export({ format: 'jwk' });
clientKey.alg = 'ES256';
Expand All @@ -186,12 +200,29 @@ test('nullable private-key JWT durations use provider defaults', () => {
tokenEndpoint: 'https://issuer.invalid/oauth/token',
clientId: 'breg-node-binding-test-client',
clientKey,
assertionLifetimeSeconds: null,
refreshMarginSeconds: null,
attemptTimeoutMilliseconds: 5_000,
},
},
};
assert.ok(new BaseRegistryClient(config));
assert.throws(() => new BaseRegistryClient({
...config,
authorization: {
privateKeyJwt: {
...config.authorization.privateKeyJwt,
requestTimeoutMilliseconds: 5_000,
},
},
}), (error) => error instanceof BaseRegistryClientError && error.kind === 'configuration');
assert.throws(() => new BaseRegistryClient({
...config,
authorization: {
privateKeyJwt: {
...config.authorization.privateKeyJwt,
assertionLifetimeSeconds: null,
},
},
}), (error) => error instanceof BaseRegistryClientError && error.kind === 'configuration');
assert.throws(() => new BaseRegistryClient({
...config,
authorization: {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,26 @@ async function form(request) {
return new URLSearchParams(Buffer.concat(chunks).toString());
}

test('exchange configuration rejects null, retired keys, and unsafe integers before I/O', () => {
const exchange = {
client: clientConfig('https://issuer.example/token', 'urn:records', ['records:get']),
context: { issuer: 'https://portal.example', subject: 'person-1', audience: 'https://issuer.example', generation: 'verified-1', deadlineSeconds: Math.floor(Date.now() / 1000) + 120 },
firstParty: { key: key(), attributes: { registry_actor_kind: 'human' } },
};
for (const changed of [
{ ...exchange, context: { ...exchange.context, grantId: null } },
{ ...exchange, client: { ...exchange.client, attemptTimeoutMilliseconds: Number.MAX_SAFE_INTEGER + 1 } },
{ ...exchange, client: { ...exchange.client, requestTimeoutMilliseconds: 100 } },
{ ...exchange, client: { ...exchange.client, attemptTimeoutSeconds: 0.1 } },
{ ...exchange, client: { ...exchange.client, request_timeout_milliseconds: 100 } },
]) {
assert.throws(
() => new BaseRegistryClient({ baseUrl: 'https://registry.example', authorization: { exchange: changed } }),
(error) => error instanceof BaseRegistryClientError && error.kind === 'configuration',
);
}
});

test('first-party option signs one verified person context and caches its token', async () => {
const exchanges = [];
const { server, origin } = await serve(async (request, response) => {
Expand Down
20 changes: 19 additions & 1 deletion crates/registry-breg-client-node/__test__/facade.test-d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,15 @@ import {
} from '..'

declare const client: BaseRegistryClient
new BaseRegistryClient({
baseUrl: 'https://registry.example.test/',
maximumResponseBytes: 8 * 1024 * 1024,
trustedRootCertificates: '-----BEGIN CERTIFICATE-----\nplaceholder\n-----END CERTIFICATE-----',
})
// @ts-expect-error The abbreviated response-bound spelling is retired.
new BaseRegistryClient({ baseUrl: 'https://registry.example.test/', maxResponseBytes: 1024 })
// @ts-expect-error Optional configuration values are omitted rather than set to null.
new BaseRegistryClient({ baseUrl: 'https://registry.example.test/', maximumResponseBytes: null })
declare const failure: BaseRegistryClientError
if (failure.constraint?.constraintKind === 'compare') {
failure.constraint.left.toUpperCase()
Expand Down Expand Up @@ -236,8 +245,17 @@ client.createRecordJson(create, { wide: 9007199254740992 }, 'exact-create')

client.statisticsLive('population', null, null, 'reader', 'csv').then((raw) => raw.reprDigest?.toUpperCase())

const once = new BaseRegistryClient({ baseUrl: 'https://registry.example.test/', maxMutationRetries: 0 })
const once = new BaseRegistryClient({
baseUrl: 'https://registry.example.test/',
attemptTimeoutMilliseconds: 30_000,
connectTimeoutMilliseconds: 10_000,
maximumMutationRetries: 0,
})
void once
// @ts-expect-error Retired option names are refused before I/O.
new BaseRegistryClient({ baseUrl: 'https://registry.example.test/', requestTimeoutMilliseconds: 30_000 })
// @ts-expect-error Retired option names are refused before I/O.
new BaseRegistryClient({ baseUrl: 'https://registry.example.test/', maxMutationRetries: 0 })

function mayHaveTakenEffect(error: BaseRegistryClientError): boolean {
const unknown: boolean = error.outcomeUnknown
Expand Down
Loading
Loading