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
38 changes: 6 additions & 32 deletions .github/workflows/csharp.yml
Original file line number Diff line number Diff line change
Expand Up @@ -367,22 +367,9 @@ jobs:
steps.version.outputs.already_released == 'true' ||
(steps.check_release.outputs.should_release == 'true' && steps.check_release.outputs.skip_bump == 'true'))
run: |
PACKAGE_ID="${{ steps.package.outputs.id }}"
PACKAGE_ID_LOWER="${{ steps.package.outputs.flat_container_id }}"
VERSION="${{ steps.release_version.outputs.version }}"
for DELAY in 0 5 10 20 30 60; do
if [ "$DELAY" != "0" ]; then
sleep "$DELAY"
fi
STATUS=$(curl -sS -o /dev/null -w '%{http_code}' "https://api.nuget.org/v3-flatcontainer/${PACKAGE_ID_LOWER}/${VERSION}/${PACKAGE_ID_LOWER}.nuspec" || true)
echo "NuGet status for ${PACKAGE_ID}@${VERSION}: ${STATUS}"
if [ "$STATUS" = "200" ]; then
echo "Verified ${PACKAGE_ID}@${VERSION} is available on NuGet"
exit 0
fi
done
echo "::error title=NuGet verification failed::${PACKAGE_ID}@${VERSION} was not available from NuGet after publish."
exit 1
node scripts/wait-for-nuget.mjs \
--package-id "${{ steps.package.outputs.id }}" \
--release-version "${{ steps.release_version.outputs.version }}"

- name: Create GitHub Release
if: >-
Expand Down Expand Up @@ -481,22 +468,9 @@ jobs:
steps.version.outputs.version_committed == 'true' ||
steps.version.outputs.already_released == 'true')
run: |
PACKAGE_ID="${{ steps.package.outputs.id }}"
PACKAGE_ID_LOWER="${{ steps.package.outputs.flat_container_id }}"
VERSION="${{ steps.version.outputs.new_version }}"
for DELAY in 0 5 10 20 30 60; do
if [ "$DELAY" != "0" ]; then
sleep "$DELAY"
fi
STATUS=$(curl -sS -o /dev/null -w '%{http_code}' "https://api.nuget.org/v3-flatcontainer/${PACKAGE_ID_LOWER}/${VERSION}/${PACKAGE_ID_LOWER}.nuspec" || true)
echo "NuGet status for ${PACKAGE_ID}@${VERSION}: ${STATUS}"
if [ "$STATUS" = "200" ]; then
echo "Verified ${PACKAGE_ID}@${VERSION} is available on NuGet"
exit 0
fi
done
echo "::error title=NuGet verification failed::${PACKAGE_ID}@${VERSION} was not available from NuGet after publish."
exit 1
node scripts/wait-for-nuget.mjs \
--package-id "${{ steps.package.outputs.id }}" \
--release-version "${{ steps.version.outputs.new_version }}"

- name: Create GitHub Release
if: >-
Expand Down
83 changes: 83 additions & 0 deletions csharp/scripts/release-scripts.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,13 @@ import test from 'node:test';
import { promisify } from 'node:util';

import { decide, readCsprojInfo } from './check-release-needed.mjs';
import {
DEFAULT_MAX_ATTEMPTS,
DEFAULT_SLEEP_SECONDS,
createNugetNuspecUrl,
parseArgs as parseWaitForNugetArgs,
waitForNugetPackage,
} from './wait-for-nuget.mjs';

const execFileAsync = promisify(execFile);

Expand Down Expand Up @@ -381,3 +388,79 @@ test('check-release-needed CLI short-circuits when NuGet and GitHub already have
assert.match(outputs, /^nuget_published=true$/m);
assert.match(outputs, /^github_release_exists=true$/m);
});

test('wait-for-nuget defaults to two-minute checks across the NuGet indexing window', () => {
const config = parseWaitForNugetArgs(
['--package-id', 'clink', '--release-version', '2.4.0'],
{}
);

assert.equal(config.packageId, 'clink');
assert.equal(config.releaseVersion, '2.4.0');
assert.equal(config.maxAttempts, DEFAULT_MAX_ATTEMPTS);
assert.equal(config.sleepSeconds, DEFAULT_SLEEP_SECONDS);
assert.equal(DEFAULT_MAX_ATTEMPTS, 8);
assert.equal(DEFAULT_SLEEP_SECONDS, 120);
});

test('wait-for-nuget builds the flat-container nuspec URL', () => {
assert.equal(
createNugetNuspecUrl({
flatContainerUrl: 'https://api.nuget.org/v3-flatcontainer/',
packageId: 'Clink',
version: '2.4.0',
}),
'https://api.nuget.org/v3-flatcontainer/clink/2.4.0/clink.nuspec'
);
});

test('wait-for-nuget succeeds when indexing takes longer than the old 125 second loop', async () => {
let attempts = 0;
const sleeps = [];

const available = await waitForNugetPackage({
checkAvailability: async () => {
attempts++;
return {
available: attempts === 8,
status: attempts === 8 ? 200 : 404,
};
},
maxAttempts: 8,
packageId: 'clink',
sleepFn: async (seconds) => {
sleeps.push(seconds);
},
sleepSeconds: 120,
stdout: () => {},
version: '2.4.0',
});

assert.equal(available, true);
assert.equal(attempts, 8);
assert.deepEqual(sleeps, [120, 120, 120, 120, 120, 120, 120]);
});

test('wait-for-nuget fails only after exhausting all attempts', async () => {
let attempts = 0;
const sleeps = [];

const available = await waitForNugetPackage({
checkAvailability: async () => {
attempts++;
return { available: false, status: 404 };
},
maxAttempts: 3,
packageId: 'clink',
sleepFn: async (seconds) => {
sleeps.push(seconds);
},
sleepSeconds: 120,
stdout: () => {},
version: '2.4.0',
});

assert.equal(available, false);
assert.equal(attempts, 3);
assert.deepEqual(sleeps, [120, 120]);
});
238 changes: 238 additions & 0 deletions csharp/scripts/wait-for-nuget.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,238 @@
#!/usr/bin/env node

/**
* Wait for a NuGet package version to become available from the flat-container API.
*
* NuGet package validation and indexing usually finish within 15 minutes. The
* release workflow uses this script after `dotnet nuget push` so GitHub releases
* are created only after the package can be restored by users.
*
* Usage:
* node csharp/scripts/wait-for-nuget.mjs --package-id <id> --release-version <version>
*
* Optional arguments:
* --max-attempts <count> Defaults to 8.
* --sleep-seconds <count> Defaults to 120.
* --flat-container-url <url> Defaults to https://api.nuget.org/v3-flatcontainer.
*
* Outputs (written to GITHUB_OUTPUT):
* - nuget_available: 'true' when the package version is visible.
*/

import { appendFileSync } from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';

const DEFAULT_FLAT_CONTAINER_URL = 'https://api.nuget.org/v3-flatcontainer';
export const DEFAULT_MAX_ATTEMPTS = 8;
export const DEFAULT_SLEEP_SECONDS = 120;

function readCliOptions(argv) {
const options = {};

for (let index = 0; index < argv.length; index++) {
const arg = argv[index];
if (!arg.startsWith('--')) {
continue;
}

const inlineValueIndex = arg.indexOf('=');
if (inlineValueIndex !== -1) {
options[arg.slice(2, inlineValueIndex)] = arg.slice(inlineValueIndex + 1);
continue;
}

const value = argv[index + 1];
if (value === undefined || value.startsWith('--')) {
throw new Error(`Missing value for ${arg}`);
}

options[arg.slice(2)] = value;
index++;
}

return options;
}

function parsePositiveInteger(value, optionName) {
const parsed = Number(value);
if (!Number.isInteger(parsed) || parsed < 1) {
throw new Error(`${optionName} must be a positive integer`);
}
return parsed;
}

export function parseArgs(argv, env = process.env) {
const cliOptions = readCliOptions(argv);

return {
flatContainerUrl:
cliOptions['flat-container-url'] ||
env.NUGET_FLAT_CONTAINER_URL ||
env.NUGET_INDEX_URL ||
DEFAULT_FLAT_CONTAINER_URL,
maxAttempts: parsePositiveInteger(
cliOptions['max-attempts'] ||
env.NUGET_WAIT_MAX_ATTEMPTS ||
env.MAX_ATTEMPTS ||
String(DEFAULT_MAX_ATTEMPTS),
'--max-attempts'
),
packageId: cliOptions['package-id'] || env.PACKAGE_ID || '',
releaseVersion:
cliOptions['release-version'] ||
cliOptions.version ||
env.RELEASE_VERSION ||
env.VERSION ||
'',
sleepSeconds: parsePositiveInteger(
cliOptions['sleep-seconds'] ||
env.NUGET_WAIT_SLEEP_SECONDS ||
env.SLEEP_SECONDS ||
String(DEFAULT_SLEEP_SECONDS),
'--sleep-seconds'
),
};
}

export function createNugetNuspecUrl({
flatContainerUrl = DEFAULT_FLAT_CONTAINER_URL,
packageId,
version,
}) {
const baseUrl = flatContainerUrl.replace(/\/+$/, '');
const lowerPackageId = packageId.toLowerCase();
const lowerVersion = version.toLowerCase();
return `${baseUrl}/${lowerPackageId}/${lowerVersion}/${lowerPackageId}.nuspec`;
}

export async function checkNugetPackageVersion({
fetchImpl = fetch,
flatContainerUrl = DEFAULT_FLAT_CONTAINER_URL,
packageId,
version,
}) {
const url = createNugetNuspecUrl({ flatContainerUrl, packageId, version });

try {
const response = await fetchImpl(url, { method: 'HEAD' });
return {
available: response.status === 200,
status: response.status,
url,
};
} catch (error) {
return {
available: false,
error: error.message,
status: 'network-error',
url,
};
}
}

function sleep(seconds) {
return new Promise((resolve) => {
globalThis.setTimeout(resolve, seconds * 1000);
});
}

function setOutput(name, value) {
const outputFile = process.env.GITHUB_OUTPUT;
if (outputFile) {
appendFileSync(outputFile, `${name}=${value}\n`);
}
console.log(`Output: ${name}=${value}`);
}

export async function waitForNugetPackage({
checkAvailability = checkNugetPackageVersion,
flatContainerUrl = DEFAULT_FLAT_CONTAINER_URL,
maxAttempts = DEFAULT_MAX_ATTEMPTS,
packageId,
sleepFn = sleep,
sleepSeconds = DEFAULT_SLEEP_SECONDS,
stdout = console.log,
version,
}) {
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
const result = await checkAvailability({
flatContainerUrl,
packageId,
version,
});

stdout(
`NuGet availability for ${packageId}@${version}: ${result.status} ` +
`(attempt ${attempt}/${maxAttempts})`
);

if (result.available) {
return true;
}

if (result.error) {
stdout(`NuGet availability check warning: ${result.error}`);
}

if (attempt < maxAttempts) {
stdout(`Waiting ${sleepSeconds}s before the next NuGet availability check`);
await sleepFn(sleepSeconds);
}
}

return false;
}

export async function main({
argv = process.argv.slice(2),
env = process.env,
stderr = console.error,
stdout = console.log,
} = {}) {
try {
const config = parseArgs(argv, env);
if (!config.packageId || !config.releaseVersion) {
stderr(
'Error: --package-id and --release-version are required for NuGet availability checks'
);
return 1;
}

stdout(
`Waiting for ${config.packageId}@${config.releaseVersion} on NuGet ` +
`(${config.maxAttempts} attempts, ${config.sleepSeconds}s interval)`
);

const available = await waitForNugetPackage({
flatContainerUrl: config.flatContainerUrl,
maxAttempts: config.maxAttempts,
packageId: config.packageId,
sleepSeconds: config.sleepSeconds,
stdout,
version: config.releaseVersion,
});

setOutput('nuget_available', available ? 'true' : 'false');

if (!available) {
stderr(
`${config.packageId}@${config.releaseVersion} did not become available on NuGet`
);
return 1;
}

stdout(`${config.packageId}@${config.releaseVersion} is available on NuGet`);
return 0;
} catch (error) {
stderr(`Error: ${error.message}`);
return 1;
}
}

const invokedDirectly = process.argv[1]
&& fileURLToPath(import.meta.url) === path.resolve(process.argv[1]);

if (invokedDirectly) {
process.exitCode = await main();
}
Loading
Loading