diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index 2090e0031..34b5836eb 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -182,7 +182,7 @@ jobs: needs: prepare runs-on: ubuntu-24.04 environment: npm-production - timeout-minutes: 30 + timeout-minutes: 90 permissions: attestations: write contents: read @@ -320,7 +320,7 @@ jobs: # Infra components are not pnpm workspaces. Keep their npm locks # peer-aware so Dockerfile npm ci remains reproducible. - for directory in infra/*/; do + for directory in infra/*/ infra/uhrp-server-cloud-bucket/notifier/; do [ -f "$directory/package.json" ] || continue [ -f "$directory/package-lock.json" ] || continue echo "Refreshing lockfile in $directory" diff --git a/docs/about/versioning.md b/docs/about/versioning.md index e540c205c..f2a98b157 100644 --- a/docs/about/versioning.md +++ b/docs/about/versioning.md @@ -47,7 +47,8 @@ Then inspect its first-party dependents: 3. Breaking first-party changes require one coordinated migration across all affected dependents before publication. 4. Infrastructure manifests and locks are reconciled after a successful - cascade release; they are deployed separately as immutable images. + cascade release, including the separately deployed UHRP notifier function. + Service images and the notifier function are promoted separately. Workspace runtime and development references use `workspace:^`; public peer dependencies use reviewed public ranges. `scripts/check-versions.mjs` rejects diff --git a/docs/reference/npm-package-supply-chain.md b/docs/reference/npm-package-supply-chain.md index 2ebc2d04e..49526c4f7 100644 --- a/docs/reference/npm-package-supply-chain.md +++ b/docs/reference/npm-package-supply-chain.md @@ -76,7 +76,10 @@ build or lifecycle code, and: No build, pack, code-generation, or version-rewrite step may run between attestation and publication. Published npm versions are immutable. A partial retry skips an already-published version only when its registry digest exactly -matches the staged artifact. +matches the staged artifact. Registry reconciliation polls for up to ten minutes +per newly published package; the publication job has a 90-minute overall bound. +If propagation exceeds that bound, inspect registry bytes before resuming the +same candidate. A timeout does not prove that npm rejected publication. The machine-readable contract is `governance/npm-package-supply-chain.json`. Repository health tests ratchet the diff --git a/infra/uhrp-server-cloud-bucket/README.md b/infra/uhrp-server-cloud-bucket/README.md index 1092a5fca..81b1a905a 100644 --- a/infra/uhrp-server-cloud-bucket/README.md +++ b/infra/uhrp-server-cloud-bucket/README.md @@ -254,7 +254,12 @@ must: 4. Deploy/replace the Cloud Run service in the bucket’s region. -5. Deploy the notifier Cloud Run service. +5. Deploy the notifier Cloud Run function on Node.js 24, matching its package + runtime contract. The release sync includes its separate manifest and npm + lockfile, so deploy the reviewed notifier source with the synchronized SDK + floor. Its advertisement requests use `Authorization: Bearer `; + sending the token only in a JSON body is unsupported. Validate the notifier + against the staging server before promoting the same source to production. Source pushes do not implicitly authorize a Cloud Run deployment. Promotion is an explicit operator-owned action against a verified image digest. diff --git a/infra/uhrp-server-cloud-bucket/notifier/deployProd.sh b/infra/uhrp-server-cloud-bucket/notifier/deployProd.sh index 2e52e4505..913f0da99 100755 --- a/infra/uhrp-server-cloud-bucket/notifier/deployProd.sh +++ b/infra/uhrp-server-cloud-bucket/notifier/deployProd.sh @@ -9,7 +9,7 @@ echo "Bucket $GCP_BUCKET_NAME is in region: $BUCKET_REGION" gcloud functions deploy prodNotifier \ --gen2 \ - --runtime=nodejs22 \ + --runtime=nodejs24 \ --env-vars-file=prod.functions.env.yaml \ --entry-point=notifier \ --timeout=540 \ @@ -17,4 +17,4 @@ gcloud functions deploy prodNotifier \ --trigger-event=google.storage.object.finalize \ --trigger-resource=$GCP_BUCKET_NAME \ --memory=4096 \ - --source . \ No newline at end of file + --source . diff --git a/infra/uhrp-server-cloud-bucket/notifier/deployStaging.sh b/infra/uhrp-server-cloud-bucket/notifier/deployStaging.sh index 354c4a576..786019d4f 100755 --- a/infra/uhrp-server-cloud-bucket/notifier/deployStaging.sh +++ b/infra/uhrp-server-cloud-bucket/notifier/deployStaging.sh @@ -9,7 +9,7 @@ echo "Bucket $GCP_BUCKET_NAME is in region: $BUCKET_REGION" gcloud functions deploy stagingNotifier \ --gen2 \ - --runtime=nodejs22 \ + --runtime=nodejs24 \ --env-vars-file=staging.functions.env.yaml \ --entry-point=notifier \ --timeout=540 \ @@ -17,4 +17,4 @@ gcloud functions deploy stagingNotifier \ --trigger-event=google.storage.object.finalize \ --trigger-resource=$GCP_BUCKET_NAME \ --memory=4096 \ - --source . \ No newline at end of file + --source . diff --git a/scripts/package-release-artifacts.mjs b/scripts/package-release-artifacts.mjs index 401455caa..f4906b5ae 100644 --- a/scripts/package-release-artifacts.mjs +++ b/scripts/package-release-artifacts.mjs @@ -28,7 +28,9 @@ const EXPECTED_WORKFLOW = '.github/workflows/release.yaml' const POLICY_PATH = path.join(REPOSITORY_ROOT, 'governance/npm-package-supply-chain.json') const COMMAND_TIMEOUT_MS = 10 * 60_000 const MAX_BUFFER_BYTES = 64 * 1024 * 1024 -const REGISTRY_RETRY_ATTEMPTS = 20 +// npm can acknowledge publication before its public metadata exposes the version. +// Keep reconciliation bounded while allowing the observed propagation delay. +const REGISTRY_RETRY_ATTEMPTS = 41 const REGISTRY_RETRY_DELAY_MS = 15_000 const URL_NAMESPACE_UUID = '6ba7b811-9dad-11d1-80b4-00c04fd430c8' const PACKED_MANIFEST_DEPENDENCY_FIELDS = [ diff --git a/scripts/sync-versions.mjs b/scripts/sync-versions.mjs index d3cd0269d..c16084365 100644 --- a/scripts/sync-versions.mjs +++ b/scripts/sync-versions.mjs @@ -7,7 +7,7 @@ * dependency reference (dependencies, devDependencies, peerDependencies) * so that they point at the current workspace version. * - * Also walks ./infra/* package.json files (which are NOT in the pnpm + * Also walks ./infra/* and the nested UHRP notifier package.json files (NOT in the pnpm * workspace) and rewrites their @bsv/* dependency ranges to track the * latest workspace versions. When an infra component's deps change, its * own version is patch-bumped so the infra-release workflow rebuilds @@ -124,7 +124,7 @@ console.log( `\n${DRY_RUN ? '[DRY RUN] Would update' : 'Updated'} ${totalChanges} cross-package references` ) -// --- 3. Sync ./infra/* (not part of pnpm workspace) --- +// --- 3. Sync standalone infrastructure (not part of pnpm workspace) --- // // Infra components consume workspace packages from the npm registry, not via // `workspace:*`. After a publish, their `^X.Y.Z` ranges go stale relative to @@ -180,9 +180,13 @@ if (!WORKSPACE_ONLY) { typeof error === 'object' && error !== null && 'code' in error && error.code === 'ENOENT' if (!missing) throw error } - for (const entry of entries) { - if (!entry.isDirectory()) continue - const componentDir = join(INFRA_DIR, entry.name) + // Only owned service roots and the separately deployed notifier participate. + // Never recurse into node_modules or arbitrary nested examples. + const componentDirs = entries + .filter(entry => entry.isDirectory()) + .map(entry => join(INFRA_DIR, entry.name)) + componentDirs.push(join(INFRA_DIR, 'uhrp-server-cloud-bucket', 'notifier')) + for (const componentDir of componentDirs) { const jsonPath = join(componentDir, 'package.json') const raw = readUtf8FileIfExists(jsonPath) if (raw === undefined) continue diff --git a/scripts/sync-versions.test.mjs b/scripts/sync-versions.test.mjs new file mode 100644 index 000000000..3b47ba90f --- /dev/null +++ b/scripts/sync-versions.test.mjs @@ -0,0 +1,77 @@ +import assert from 'node:assert/strict' +import { execFileSync } from 'node:child_process' +import { copyFileSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { dirname, join } from 'node:path' +import test from 'node:test' +import { fileURLToPath } from 'node:url' + +const scriptDirectory = dirname(fileURLToPath(import.meta.url)) + +test('release sync covers the separately deployed notifier without traversing arbitrary children', () => { + const root = mkdtempSync(join(tmpdir(), 'ts-stack-release-sync-')) + const writeJson = (relative, value) => { + const file = join(root, relative) + mkdirSync(dirname(file), { recursive: true }) + writeFileSync(file, `${JSON.stringify(value, null, 2)}\n`) + } + const readJson = relative => JSON.parse(readFileSync(join(root, relative), 'utf8')) + const service = 'infra/uhrp-server-cloud-bucket/package.json' + const notifier = 'infra/uhrp-server-cloud-bucket/notifier/package.json' + const ignored = 'infra/uhrp-server-cloud-bucket/examples/package.json' + try { + mkdirSync(join(root, 'scripts')) + for (const file of ['sync-versions.mjs', 'file-system.mjs']) { + copyFileSync(join(scriptDirectory, file), join(root, 'scripts', file)) + } + writeJson('package.json', { name: 'release-sync-fixture', private: true }) + writeFileSync(join(root, 'pnpm-workspace.yaml'), "packages:\n - 'packages/*'\n") + writeJson('packages/sdk/package.json', { name: '@bsv/sdk', version: '2.8.0' }) + // Repository-health CI intentionally runs before package-manager setup. + // Supply only the unchanged workspace-discovery boundary to this fixture. + const bin = join(root, 'bin') + mkdirSync(bin) + const workspaceListing = JSON.stringify([ + { name: '@bsv/sdk', version: '2.8.0', path: join(root, 'packages/sdk') } + ]) + writeFileSync( + join(bin, 'pnpm'), + `#!/usr/bin/env node\nprocess.stdout.write(${JSON.stringify(workspaceListing)})\n`, + { mode: 0o700 } + ) + const oldManifest = { + name: 'standalone-consumer', + version: '1.0.0', + dependencies: { '@bsv/sdk': '^2.1.9', axios: '^1.18.1' } + } + for (const path of [service, notifier, ignored]) writeJson(path, oldManifest) + const run = (...args) => + execFileSync(process.execPath, [join(root, 'scripts/sync-versions.mjs'), ...args], { + cwd: root, + encoding: 'utf8', + env: { ...process.env, PATH: `${bin}:${dirname(process.execPath)}` } + }) + + assert.match( + run('--dry-run'), + /Would update 2 infra dep reference\(s\) across 2 component\(s\)/ + ) + for (const path of [service, notifier, ignored]) assert.deepEqual(readJson(path), oldManifest) + run('--workspace-only') + assert.deepEqual(readJson(notifier), oldManifest) + + run() + for (const path of [service, notifier]) { + assert.deepEqual(readJson(path), { + ...oldManifest, + version: '1.0.1', + dependencies: { '@bsv/sdk': '^2.8.0', axios: '^1.18.1' } + }) + } + assert.deepEqual(readJson(ignored), oldManifest) + assert.match(run(), /Updated 0 infra dep reference\(s\) across 0 component\(s\)/) + assert.equal(readJson(notifier).version, '1.0.1') + } finally { + rmSync(root, { recursive: true, force: true }) + } +})