diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 999db4e..490eb99 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -252,7 +252,9 @@ jobs: mkdir -p "$AUDIT_ROOT" cd "$AUDIT_ROOT" npm init --yes >/dev/null - npm install --no-audit --no-fund --ignore-scripts "@tangle-network/braid@$VERSION" + bash "$GITHUB_WORKSPACE/scripts/release/retry-registry-command.sh" \ + npm install --prefer-online --no-audit --no-fund --ignore-scripts \ + "@tangle-network/braid@$VERSION" npm audit signatures --json --include-attestations > "$RUNNER_TEMP/npm-audit-signatures.json" - name: Upload npm provenance diff --git a/scripts/release/retry-registry-command.sh b/scripts/release/retry-registry-command.sh new file mode 100644 index 0000000..2928305 --- /dev/null +++ b/scripts/release/retry-registry-command.sh @@ -0,0 +1,33 @@ +#!/usr/bin/env bash + +set -euo pipefail + +attempts="${BRAID_REGISTRY_ATTEMPTS:-30}" +delay_seconds="${BRAID_REGISTRY_DELAY_SECONDS:-5}" + +if [[ ! "$attempts" =~ ^[1-9][0-9]*$ ]]; then + echo 'BRAID_REGISTRY_ATTEMPTS must be a positive integer' >&2 + exit 64 +fi +if [[ ! "$delay_seconds" =~ ^[0-9]+$ ]]; then + echo 'BRAID_REGISTRY_DELAY_SECONDS must be a non-negative integer' >&2 + exit 64 +fi +if (( $# == 0 )); then + echo 'A registry command is required' >&2 + exit 64 +fi + +attempt=1 +while (( attempt <= attempts )); do + if "$@"; then + exit 0 + fi + if (( attempt == attempts )); then + echo "Registry command failed after $attempts attempts" >&2 + exit 1 + fi + echo "Waiting for registry propagation ($attempt/$attempts)" >&2 + sleep "$delay_seconds" + ((attempt += 1)) +done diff --git a/scripts/release/smoke-package.mjs b/scripts/release/smoke-package.mjs index a137da9..58231a4 100644 --- a/scripts/release/smoke-package.mjs +++ b/scripts/release/smoke-package.mjs @@ -134,6 +134,7 @@ async function sha256(path) { const artifactRootValue = option('--artifact-root') ?? process.env.BRAID_RELEASE_ARTIFACT_ROOT const registrySpec = option('--registry') ?? process.env.BRAID_RELEASE_REGISTRY_SPEC const outputPathValue = option('--output') ?? process.env.BRAID_SMOKE_OUTPUT +const retryRegistryCommand = fileURLToPath(new URL('./retry-registry-command.sh', import.meta.url)) const expectedPlatform = option('--expect-platform') ?? process.env.BRAID_EXPECT_PLATFORM const expectedArchitecture = option('--expect-architecture') ?? process.env.BRAID_EXPECT_ARCHITECTURE @@ -149,7 +150,11 @@ try { const packRoot = join(smokeRoot, 'registry') await mkdir(packRoot) const npm = npmInvocation(['pack', registrySpec, '--pack-destination', packRoot]) - await run(npm.file, npm.args, { cwd: smokeRoot }) + await run('bash', [retryRegistryCommand, npm.file, ...npm.args], { + cwd: smokeRoot, + label: 'Registry package download', + timeoutMs: 5 * 60_000, + }) const archives = (await readdir(packRoot)).filter((name) => name.endsWith('.tgz')) assert(archives.length === 1, 'Registry download did not produce exactly one tarball') tarballPath = join(packRoot, archives[0]) diff --git a/test/scripts.test.ts b/test/scripts.test.ts index 515c03c..359ea11 100644 --- a/test/scripts.test.ts +++ b/test/scripts.test.ts @@ -1,5 +1,5 @@ import assert from 'node:assert/strict' -import { execFileSync } from 'node:child_process' +import { execFileSync, spawnSync } from 'node:child_process' import { createHash } from 'node:crypto' import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' @@ -85,6 +85,44 @@ test('compiled tests receive the JavaScript helpers imported from scripts', asyn assert.match(source, /entry\.name\.endsWith\('\.mjs'\)/u) }) +test('registry commands retry transient failures and stop at the configured limit', async () => { + const root = await mkdtemp(join(tmpdir(), 'braid-registry-retry-')) + const counter = join(root, 'attempts') + const retry = join(process.cwd(), 'scripts', 'release', 'retry-registry-command.sh') + const countAttempt = [ + "const fs = require('node:fs')", + 'const path = process.argv[1]', + "const count = Number(fs.existsSync(path) ? fs.readFileSync(path, 'utf8') : '0') + 1", + 'fs.writeFileSync(path, String(count))', + "process.exit(count < Number(process.env.SUCCEED_ON ?? '999') ? 42 : 0)", + ].join(';') + const environment = { + ...process.env, + BRAID_REGISTRY_ATTEMPTS: '3', + BRAID_REGISTRY_DELAY_SECONDS: '0', + } + + try { + const recovered = spawnSync('bash', [retry, process.execPath, '-e', countAttempt, counter], { + encoding: 'utf8', + env: { ...environment, SUCCEED_ON: '3' }, + }) + assert.equal(recovered.status, 0, recovered.stderr) + assert.equal(await readFile(counter, 'utf8'), '3') + + await writeFile(counter, '0') + const exhausted = spawnSync('bash', [retry, process.execPath, '-e', countAttempt, counter], { + encoding: 'utf8', + env: environment, + }) + assert.equal(exhausted.status, 1) + assert.match(exhausted.stderr, /Registry command failed after 3 attempts/u) + assert.equal(await readFile(counter, 'utf8'), '3') + } finally { + await rm(root, { recursive: true, force: true }) + } +}) + test('clean package installs cannot inherit disabled native dependency builds', () => { const environment = nativeInstallEnvironment({ NPM_CONFIG_IGNORE_SCRIPTS: 'true',