Skip to content
Open
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
20 changes: 19 additions & 1 deletion __tests__/image-service.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ describe('ImageService', () => {
describe('when asked to fetch non-GitHub hosted images', () => {
test('it raises an error', async () => {
await expect(ImageService.pull('hello-world')).rejects.toThrow(
'Only images distributed via docker.pkg.github.com or ghcr.io can be fetched'
'Only images distributed via docker.pkg.github.com, ghcr.io or azure-api.net can be fetched'
)
})
})
Expand Down Expand Up @@ -143,4 +143,22 @@ describe('ImageService.fetchImageWithRetry', () => {

expect(sendMetricsMock).not.toHaveBeenCalled()
})

test('can pull images from azure-api.net', async () => {
pullMock.mockResolvedValue(
new Readable({
read() {}
})
)

await expect(
ImageService.fetchImageWithRetry(
'test.azure-api.net/ghcr.io/dependabot/dependabot-updater-npm',
{},
docker,
undefined, // explicitly pass undefined for sendMetrics
'dependabot'
)
).resolves.not.toThrow()
})
Comment on lines +147 to +163
Copy link

Copilot AI Feb 6, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This test exercises fetchImageWithRetry with an azure-api.net image name, but the new repository allowlist behavior is enforced in ImageService.pull() via validImageRepository(). Add a unit test that calls ImageService.pull() with an *.azure-api.net/... image and asserts it does not throw (mocking Docker as needed), so the allowlist change is actually covered.

Copilot uses AI. Check for mistakes.
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think that's necessary.

})
43 changes: 42 additions & 1 deletion __tests__/main.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -409,7 +409,7 @@ describe('run', () => {
})
})

describe('when there is an error pulling images', () => {
describe('when there is an error pulling all images', () => {
beforeEach(() => {
jest
.spyOn(ImageService, 'pull')
Expand All @@ -418,6 +418,12 @@ describe('run', () => {
Promise.reject(new Error('error pulling an image'))
)
)
.mockImplementationOnce(
// when calling Azure
jest.fn(async () =>
Promise.reject(new Error('error pulling an image'))
)
)
jest.spyOn(ApiClient.prototype, 'getJobDetails').mockImplementationOnce(
jest.fn(async () => {
return {'package-manager': 'npm_and_yarn'} as JobDetails
Expand All @@ -426,6 +432,10 @@ describe('run', () => {
context = new Context()
})

afterAll(() => {
jest.restoreAllMocks()
})

test('it fails the workflow', async () => {
await run(context)

Expand All @@ -447,6 +457,37 @@ describe('run', () => {
})
})

describe('when there is an error pulling first images', () => {
beforeEach(() => {
jest
.spyOn(ImageService, 'pull')
.mockImplementationOnce(
jest.fn(async () =>
Promise.reject(new Error('error pulling an image'))
)
)
jest.spyOn(ApiClient.prototype, 'getJobDetails').mockImplementationOnce(
jest.fn(async () => {
return {'package-manager': 'npm_and_yarn'} as JobDetails
})
)
context = new Context()
})

afterAll(() => {
jest.restoreAllMocks()
})

test('it succeeds the workflow', async () => {
await run(context)

expect(core.setFailed).not.toHaveBeenCalled()
expect(core.info).toHaveBeenCalledWith(
expect.stringContaining('🤖 ~ finished ~')
)
})
})

describe('when there the update container exits with an error signal', () => {
beforeEach(() => {
jest
Expand Down
42 changes: 31 additions & 11 deletions dist/main/index.js

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

2 changes: 1 addition & 1 deletion dist/main/index.js.map

Large diffs are not rendered by default.

2 changes: 2 additions & 0 deletions docker/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,3 +9,5 @@ This allows us to use Dependabot to keep these SHAs up to date as new versions o

These Dockerfiles are not actually built by the Action or any CI processes, they are purely used as compile-time
configuration to generate `containers.json` which is used at runtime.

These images are also stored in a fallback registry in Azure under the same path as the GitHub Container Registry.
2 changes: 1 addition & 1 deletion docker/containers.json
Original file line number Diff line number Diff line change
Expand Up @@ -28,4 +28,4 @@
"julia": "ghcr.io/dependabot/dependabot-updater-julia:v2.0.20260108161155@sha256:ac0861bc1660c63378df1cb6555c4d140845c5524294bb93f1cf7789873f00a3",
"bazel": "ghcr.io/dependabot/dependabot-updater-bazel:v2.0.20260108161155@sha256:6640de1134b582a01df260d6a645a6d75edbf6ff80ab294aa88a7081337914cc",
"opentofu": "ghcr.io/dependabot/dependabot-updater-opentofu:v2.0.20260108161155@sha256:ec360a9e100945c887ab53dd00112d46d6ba2a05494d06fdcbb2293681cb4eae"
}
}
20 changes: 12 additions & 8 deletions src/image-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {Readable} from 'stream'

const MAX_RETRIES = 5 // Maximum number of retries
const INITIAL_DELAY_MS = 5000 // Initial delay in milliseconds for backoff
const AZURE_REGISTRY_RE = /$\w*\.azure-api\.net/g

const sleep = async (ms: number): Promise<void> =>
new Promise(resolve => setTimeout(resolve, ms))
Expand Down Expand Up @@ -39,18 +40,13 @@ export const ImageService = {
force = false
): Promise<void> {
/*
This method fetches images hosts on GitHub infrastructure.
This method fetches images hosted on GitHub or Azure infrastructure.

We expose the `fetch_image` utility method to allow us to pull in arbitrary images for unit tests.
*/
if (
!(
imageName.startsWith('ghcr.io/') ||
imageName.startsWith('docker.pkg.github.com/')
)
) {
if (!validImageRepository(imageName)) {
throw new Error(
'Only images distributed via docker.pkg.github.com or ghcr.io can be fetched'
'Only images distributed via docker.pkg.github.com, ghcr.io or azure-api.net can be fetched'
)
}

Expand Down Expand Up @@ -129,3 +125,11 @@ export const ImageService = {
}
}
}

const validImageRepository = (imageName: string): boolean => {
return (
imageName.startsWith('ghcr.io/') ||
imageName.startsWith('docker.pkg.github.com/') ||
!!imageName.match(AZURE_REGISTRY_RE)
)
}
56 changes: 37 additions & 19 deletions src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,9 @@ export enum DependabotErrorType {
UpdateRun = 'actions_workflow_updater'
}

const FALLBACK_CONTAINER_REGISTRY =
'dependabot-acr-apim-production.azure-api.net'

let jobId: number

export async function run(context: Context): Promise<void> {
Expand Down Expand Up @@ -70,8 +73,9 @@ export async function run(context: Context): Promise<void> {
const details = await apiClient.getJobDetails()

// The dynamic workflow can specify which updater image to use. If it doesn't, fall back to the pinned version.
const updaterImage =
let updaterImage =
params.updaterImage || updaterImageName(details['package-manager'])
let proxyImage = PROXY_IMAGE_NAME

// The sendMetrics function is used to send metrics to the API client.
// It uses the package manager as a tag to identify the metric.
Expand Down Expand Up @@ -105,36 +109,50 @@ export async function run(context: Context): Promise<void> {
credentials.push(packagesCred)
}

const updater = new Updater(
updaterImage,
PROXY_IMAGE_NAME,
apiClient,
details,
credentials
)

core.startGroup('Pulling updater images')
let imagesPulled = false

try {
// Using sendMetricsWithPackageManager wrapper to inject package manager tag ti
// avoid passing additional parameters to ImageService.pull method
await ImageService.pull(updaterImage, sendMetricsWithPackageManager)
await ImageService.pull(PROXY_IMAGE_NAME, sendMetricsWithPackageManager)
} catch (error: unknown) {
if (error instanceof Error) {
await failJob(
apiClient,
'Error fetching updater images',
error,
DependabotErrorType.Image
)
return
await ImageService.pull(proxyImage, sendMetricsWithPackageManager)
imagesPulled = true
} catch {
core.warning('Primary image pull failed, attempting fallback')
}
Comment on lines 115 to +123
Copy link

Copilot AI Feb 6, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

catch { core.warning('Primary image pull failed...') } discards the underlying error, which makes diagnosing registry/auth/network issues difficult. Capture the error and include its message (and ideally which image failed) in the warning log.

Copilot uses AI. Check for mistakes.

if (!imagesPulled) {
updaterImage = `${FALLBACK_CONTAINER_REGISTRY}/${updaterImage}`
proxyImage = `${FALLBACK_CONTAINER_REGISTRY}/${proxyImage}`
try {
await ImageService.pull(updaterImage, sendMetricsWithPackageManager)
await ImageService.pull(proxyImage, sendMetricsWithPackageManager)
} catch (error: unknown) {
Comment on lines 112 to +131
Copy link

Copilot AI Feb 6, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The fallback logic treats the updater+proxy pulls as all-or-nothing via a single imagesPulled flag. If the updater image pulls successfully from GHCR but the proxy image pull fails, the code will still rewrite both image names to the Azure registry and require both Azure pulls to succeed, potentially failing even though one image was already available locally. Track success per image and only fall back (and rewrite the image name passed to Updater) for the specific image(s) that failed to pull.

Copilot uses AI. Check for mistakes.
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm okay with the all-or-nothing approach. It's very unlikely one will fail and not the other

if (error instanceof Error) {
await failJob(
apiClient,
'Error fetching updater images',
error,
DependabotErrorType.Image
)
return
}
}
}
core.endGroup()
Comment on lines 112 to 143
Copy link

Copilot AI Feb 6, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If failJob(...) returns early inside the image-pull group, core.endGroup() is skipped, which can leave the GitHub Actions log grouping unbalanced. Consider wrapping the pull logic in a try/finally that always calls core.endGroup().

See below for a potential fix:

        try {
          // Using sendMetricsWithPackageManager wrapper to inject package manager tag ti
          // avoid passing additional parameters to ImageService.pull method
          await ImageService.pull(updaterImage, sendMetricsWithPackageManager)
          await ImageService.pull(proxyImage, sendMetricsWithPackageManager)
          imagesPulled = true
        } catch {
          core.warning('Primary image pull failed, attempting fallback')
        }

        if (!imagesPulled) {
          updaterImage = `${FALLBACK_CONTAINER_REGISTRY}/${updaterImage}`
          proxyImage = `${FALLBACK_CONTAINER_REGISTRY}/${proxyImage}`
          try {
            await ImageService.pull(updaterImage, sendMetricsWithPackageManager)
            await ImageService.pull(proxyImage, sendMetricsWithPackageManager)
          } catch (error: unknown) {
            if (error instanceof Error) {
              await failJob(
                apiClient,
                'Error fetching updater images',
                error,
                DependabotErrorType.Image
              )
              return
            }
          }
        }
      } finally {
        core.endGroup()
      }

Copilot uses AI. Check for mistakes.

try {
core.info('Starting update process')

const updater = new Updater(
updaterImage,
proxyImage,
apiClient,
details,
credentials
)

await updater.runUpdater()
} catch (error: unknown) {
if (error instanceof Error) {
Expand Down
Loading