Recently I got lost setting up Renovate against an internal NuGet feed. Dependency updates kept coming back with a 401, even though I had hostRules configured with credentials right there in renovate.json. No error, no obvious warning in the log that jumped out at me. Just an anonymous request going out where an authenticated one should have.
Turns out the config was fine. The location wasn't.
The problem
The error itself didn't point anywhere near the config file:
{
"message": "Request failed with status code 401 (Unauthorized): GET https://tfs.vlm.be/tfs/DefaultCollection/_packaging/VLMFeed/nuget/v3/index.json",
"response": {
"statusCode": 401,
"statusMessage": "Unauthorized",
"body": "{\"$id\":\"1\",\"innerException\":null,\"message\":\"TF400813: Resource not available for anonymous access. Client authentication required.\",\"typeName\":\"Microsoft.TeamFoundation.Framework.Server.UnauthorizedRequestException, Microsoft.TeamFoundation.Framework.Server\",\"typeKey\":\"UnauthorizedRequestException\",\"errorCode\":0,\"eventId\":3000}"
}
}
"Resource not available for anonymous access." The request went out with no credentials at all, even though I had hostRules configured. So where were those credentials actually going?
Here's the relevant part of my renovate.json:
{
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
"extends": [
"config:base"
],
"schedule": [
"before 3am on Monday"
],
"prConcurrentLimit": 2,
"semanticCommits": true,
"commitMessagePrefix": "chore(deps):",
"hostRules": [
{
"hostType": "nuget",
"matchHost": https://tfs.vlm.be/tfs/defaultcollection,
"username": "nuget",
"password": "process.env.NUGET_FEED_TOKEN"
}
]
}
Looks reasonable, right? Two hostRules entries, one generic and one scoped to nuget, both pointing at the same host. This is exactly where the problem was hiding.
renovate.json, whether it lives in the repo or is passed in as repository-level config, is a repository config. hostRules is a global-only option. Renovate loads the file, sees hostRules in there, and quietly drops it. You get a warning if you know where to look, but the combined config ends up with an empty hostRules, and that's why the request went out with no credentials attached.
Remark: the same applies to other global-only options like platform, endpoint, and autodiscover. If you're setting those in a file that's scanned per-repo, they won't do what you expect either.
The fix
The fix was simple. I had to move hostRules out of renovate.json and into the global config.
If you're self-hosting (CLI/npx, Docker, or a pipeline task), that's one of two places:
Option A — config.js, the file RENOVATE_CONFIG_FILE points to (or the default config.js next to wherever you run renovate):
module.exports = {
platform: 'azure',
endpoint: 'https://tfs.vlm.be/tfs/DefaultCollection',
token: process.env.RENOVATE_TOKEN,
hostRules: [
{
hostType: 'nuget',
matchHost: 'https://tfs.vlm.be/tfs/DefaultCollection/',
username: 'apikey',
password: process.env.NUGET_FEED_TOKEN, // PAT with Packaging (Read)
},
],
repositories: ['YourProject/YourRepo'],
};
Option B — environment variable, if your pipeline sets things via env instead of a file:
RENOVATE_HOST_RULES='[{"hostType":"nuget","matchHost":"https://tfs.vlm.be/tfs/DefaultCollection/","username":"apikey","password":"<PAT>"}]'
Everything else (extends, packageRules, schedules, and so on) can stay in renovate.json exactly as before. It's specifically the global-only options that need to move up a level.
That's did the trick! My first PR is ready...