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
24 changes: 22 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -250,8 +250,8 @@ output:
This example smoke test describes the expected behavior for Dependabot
to update the base image of a Dockerfile from `ubuntu:17.04` to `ubuntu:22.04`.

* The `input` field consists of a `job` and any `credentials`.
(this is equivalent a [job description file](#job-description-file)).
* The `input` field consists of a `job`, any `credentials`, and an optional
`recorded-at` timestamp (equivalent to a [job description file](#job-description-file)).
* The `output` field comprises an array of expectation objects.
These correspond to requests made by the updater to the Dependabot API service
when performing an update job.
Expand Down Expand Up @@ -293,6 +293,26 @@ Any cache misses indicate an external request made by the updater,
which may cause tests to fail unexpectedly
(for example, when a new version of a package is released).

### Recording time

New recordings made with `update -o` or `graph -o` capture `input.recorded-at`
as an RFC 3339 timestamp. Recording and replay use that instant for Ruby's
`Time.now` and JavaScript's `Date`, including native npm subprocesses.
This keeps release-age cooldowns from expiring between runs against the same
cached registry responses.

You can also supply `recorded-at` in a job input to reproduce a known historical
instant. The clock has millisecond precision. The override is confined to the
updater container and preserves existing `RUBYOPT` and `NODE_OPTIONS` options.
Timers, monotonic clocks, TLS certificate verification, and other runtimes'
clocks are not overridden.

Ordinary updates without an output recording or an explicit `recorded-at`
continue to use the current clock. Legacy smoke tests without `recorded-at`
also keep their existing behavior. To make one clock-consistent, create a new
recording with `update -o`; do not infer its original clock from cached HTTP
responses or change expected dependency versions by hand.

## Debugging with the CLI

See the [debugging doc](/docs/debugging.md) for details.
Expand Down
1 change: 1 addition & 0 deletions cmd/dependabot/internal/cmd/graph.go
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,7 @@ func NewGraphCommand() *cobra.Command {
ProxyCertPath: flags.proxyCertPath,
ProxyImage: proxyImage,
PullImages: flags.pullImages,
RecordedAt: input.RecordedAt,
Timeout: flags.timeout,
UpdaterImage: updaterImage,
Volumes: flags.volumes,
Expand Down
1 change: 1 addition & 0 deletions cmd/dependabot/internal/cmd/test.go
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ func NewTestCommand() *cobra.Command {
ProxyCertPath: flags.proxyCertPath,
ProxyImage: proxyImage,
PullImages: flags.pullImages,
RecordedAt: smokeTest.Input.RecordedAt,
StorageImage: storageImage,
Timeout: flags.timeout,
UpdaterImage: updaterImage,
Expand Down
1 change: 1 addition & 0 deletions cmd/dependabot/internal/cmd/update.go
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,7 @@ func NewUpdateCommand() *cobra.Command {
ProxyCertPath: flags.proxyCertPath,
ProxyImage: proxyImage,
PullImages: flags.pullImages,
RecordedAt: input.RecordedAt,
StorageImage: storageImage,
Timeout: flags.timeout,
UpdaterImage: updaterImage,
Expand Down
59 changes: 59 additions & 0 deletions internal/infra/clock.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
package infra

import (
"archive/tar"
"bytes"
"context"
_ "embed"
"fmt"
"time"

"github.com/docker/docker/api/types/container"
)

const guestClockDir = "/opt/dependabot-cli-clock"

//go:embed clock/clock.sh
var clockShell string

//go:embed clock/clock.rb
var clockRuby string

//go:embed clock/clock.cjs
var clockNode string

func (u *Updater) installClock(ctx context.Context, recordedAt time.Time) error {
var archive bytes.Buffer
writer := tar.NewWriter(&archive)
for _, file := range []struct{ name, content string }{
{"clock.sh", fmt.Sprintf(clockShell, recordedAt.UnixMilli())},
{"clock.rb", clockRuby},
{"clock.cjs", clockNode},
} {
if err := addFileToArchive(writer, guestClockDir+"/"+file.name, 0555, file.content); err != nil {
return fmt.Errorf("failed to archive replay clock: %w", err)
}
}
if err := writer.Close(); err != nil {
return fmt.Errorf("failed to close replay clock archive: %w", err)
}
if err := u.cli.CopyToContainer(ctx, u.containerID, "/", &archive, container.CopyToContainerOptions{}); err != nil {
return fmt.Errorf("failed to copy replay clock to container: %w", err)
}
u.recordedClock = true
return nil
}

func (u *Updater) command(shell string, args ...string) []string {
offset := 0
if u.recordedClock {
offset = 1
}
command := make([]string, len(args)+1+offset)
if u.recordedClock {
command[0] = guestClockDir + "/clock.sh"
}
command[offset] = shell
copy(command[offset+1:], args)
return command
}
17 changes: 17 additions & 0 deletions internal/infra/clock/clock.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
const recordedTime = Number(process.env.DEPENDABOT_RECORDED_AT)
if (!Number.isFinite(recordedTime)) {
throw new Error('Invalid Dependabot replay timestamp')
}

// Freeze wall-clock dates, not timers, performance.now(), or TLS verification.
const NativeDate = Date
NativeDate.now = () => recordedTime
globalThis.Date = new Proxy(NativeDate, {
apply() {
return new NativeDate(recordedTime).toString()
},
construct(target, args, newTarget) {
return Reflect.construct(target, args.length ? args : [recordedTime], newTarget)
},
})
NativeDate.prototype.constructor = globalThis.Date
7 changes: 7 additions & 0 deletions internal/infra/clock/clock.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
# frozen_string_literal: true

# Leave monotonic clocks and OpenSSL's certificate clock untouched.
recorded_time = Rational(Integer(ENV.fetch("DEPENDABOT_RECORDED_AT"), 10), 1000)
Time.define_singleton_method(:now) do |**options|
at(recorded_time, **options)
end
6 changes: 6 additions & 0 deletions internal/infra/clock/clock.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
#!/bin/sh
set -eu
export DEPENDABOT_RECORDED_AT=%d
export RUBYOPT="${RUBYOPT:+$RUBYOPT }-r/opt/dependabot-cli-clock/clock.rb"
export NODE_OPTIONS="${NODE_OPTIONS:+$NODE_OPTIONS }--require=/opt/dependabot-cli-clock/clock.cjs"
exec "$@"
7 changes: 7 additions & 0 deletions internal/infra/run.go
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,8 @@ type RunParams struct {
ApiUrl string
// UpdaterEnvironmentVariables are additional environment variables to set in the update container
UpdaterEnvironmentVariables []string
// RecordedAt is the clock captured by a recording, independent of the host clock.
RecordedAt *time.Time
}

var gitShaRegex = regexp.MustCompile(`^[0-9a-f]{40}$`)
Expand All @@ -120,6 +122,10 @@ func Run(params RunParams) error {
if err := params.Validate(); err != nil {
return err
}
if params.RecordedAt == nil && params.Output != "" && params.Expected == nil {
recordedAt := time.Now().UTC().Truncate(time.Millisecond)
params.RecordedAt = &recordedAt
}

var ctx context.Context
var cancel func()
Expand Down Expand Up @@ -189,6 +195,7 @@ func generateOutput(params RunParams, api *server.API, outFile *os.File) ([]byte
params.Job.Source.Commit = api.Actual.Input.Job.Source.Commit
}
api.Actual.Input.Job = *params.Job
api.Actual.Input.RecordedAt = params.RecordedAt

// ignore conditions help make tests reproducible, so they are generated if there aren't any yet
if len(api.Actual.Input.Job.IgnoreConditions) == 0 {
Expand Down
11 changes: 9 additions & 2 deletions internal/infra/updater.go
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ type Updater struct {
containerID string
storageContainerID string
storageVolumes []string
recordedClock bool

// ExitCode is set once an Updater command has completed.
ExitCode *int
Expand Down Expand Up @@ -184,6 +185,12 @@ func newUpdater(ctx context.Context, cli *client.Client, net *Networks, params *
updater.Close()
return nil, err
}
if params.RecordedAt != nil {
if err = updater.installClock(ctx, *params.RecordedAt); err != nil {
updater.Close()
return nil, err
}
}

if err = cli.ContainerStart(ctx, updaterContainer.ID, container.StartOptions{}); err != nil {
updater.Close()
Expand Down Expand Up @@ -418,7 +425,7 @@ func (u *Updater) RunShell(ctx context.Context, proxyURL string, apiUrl string,
Tty: true,
User: dependabot,
Env: append(userEnv(proxyURL, apiUrl, job, additionalEnvVars), "DEBUG=1"),
Cmd: []string{"/bin/bash"},
Cmd: u.command("/bin/bash"),
})
if err != nil {
return fmt.Errorf("failed to create exec: %w", err)
Expand Down Expand Up @@ -492,7 +499,7 @@ func (u *Updater) runCmdStreaming(ctx context.Context, cmd, user string, env ...
AttachStderr: true,
User: user,
Env: env,
Cmd: []string{"/bin/sh", "-c", cmd},
Cmd: u.command("/bin/sh", "-c", cmd),
})
if err != nil {
return 0, fmt.Errorf("failed to create exec: %w", err)
Expand Down
4 changes: 4 additions & 0 deletions internal/model/smoke.go
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
package model

import "time"

type RunCommand string

const (
Expand All @@ -24,6 +26,8 @@ type Input struct {
Job Job `yaml:"job"`
// Credentials is the registry info and tokens to pass to the Proxy
Credentials []Credential `yaml:"credentials,omitempty"`
// RecordedAt pins the Ruby and JavaScript wall clocks when replaying a recording.
RecordedAt *time.Time `yaml:"recorded-at,omitempty" json:"recorded-at,omitempty"`
}

// Output is the expected output given the inputs
Expand Down
110 changes: 110 additions & 0 deletions testdata/scripts/clock.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
# The updater runs real Ruby and npm. No external registry requests are needed.
exec docker build -qt clock-updater .

dependabot update -f input.yaml -o recorded.yaml --updater-image clock-updater
stderr 'NPM_CANDIDATE=1.0.0'
stderr 'RUBY_TIME=2026-10-02T06:10:32.000Z'

# The same metadata admits the new version after its three-day cooldown expires.
dependabot update -f later.yaml --updater-image clock-updater
stderr 'NPM_CANDIDATE=1.1.0'
stderr 'RUBY_TIME=2026-10-07T06:10:32.000Z'

# Replaying the earlier recording must not inherit the later clock.
dependabot test -f recorded.yaml --updater-image clock-updater
stderr 'NPM_CANDIDATE=1.0.0'
stderr 'RUBY_TIME=2026-10-02T06:10:32.000Z'

exec docker rmi clock-updater

-- Dockerfile --
FROM node:24-bookworm-slim
RUN apt-get update && apt-get install -y --no-install-recommends ruby ca-certificates && npm install --global npm@11.19.0
RUN useradd -m dependabot
USER dependabot
WORKDIR /home/dependabot/dependabot-updater
COPY --chown=dependabot --chmod=755 run bin/run
COPY --chown=dependabot registry.cjs registry.cjs

-- run --
#!/bin/sh
set -eu
ruby -rtime -e 'start = Process.clock_gettime(Process::CLOCK_MONOTONIC); sleep 0.01; abort "monotonic clock stopped" unless Process.clock_gettime(Process::CLOCK_MONOTONIC) > start; puts "RUBY_TIME=#{Time.now.utc.iso8601(3)}"'
node registry.cjs

-- registry.cjs --
const assert = require('node:assert/strict')
const { execFile } = require('node:child_process')
const fs = require('node:fs')
const http = require('node:http')
const os = require('node:os')
const path = require('node:path')
const { promisify } = require('node:util')

const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'clock-registry-'))
const server = http.createServer((request, response) => {
if (request.url !== '/clock-dependency') {
response.writeHead(404).end()
return
}
const origin = `http://127.0.0.1:${server.address().port}`
response.setHeader('content-type', 'application/json')
response.end(JSON.stringify({
name: 'clock-dependency',
'dist-tags': { latest: '1.1.0' },
versions: Object.fromEntries(['1.0.0', '1.1.0'].map(version => [version, {
name: 'clock-dependency', version,
dist: { tarball: `${origin}/clock-dependency-${version}.tgz` },
}])),
time: { '1.0.0': '2026-01-01T00:00:00Z', '1.1.0': '2026-10-01T13:52:31Z' },
}))
})

server.listen(0, '127.0.0.1', async () => {
try {
const manifest = { name: 'clock-test', version: '1.0.0', dependencies: { 'clock-dependency': '^1.0.0' } }
fs.writeFileSync(path.join(directory, 'package.json'), JSON.stringify(manifest))
fs.writeFileSync(path.join(directory, 'package-lock.json'), JSON.stringify({
name: 'clock-test', version: '1.0.0', lockfileVersion: 3,
packages: { '': manifest, 'node_modules/clock-dependency': { version: '1.0.0' } },
}))
const monotonic = process.hrtime.bigint()
await new Promise(resolve => setTimeout(resolve, 10))
assert(process.hrtime.bigint() > monotonic)
assert.equal(+new Date('2000-01-01T00:00:00Z'), 946684800000)
await promisify(execFile)('npm', [
'update', 'clock-dependency', '--package-lock-only', '--ignore-scripts',
'--no-audit', '--no-fund', '--min-release-age=3',
`--registry=http://127.0.0.1:${server.address().port}`, `--cache=${directory}/cache`,
], { cwd: directory, env: { ...process.env, NO_PROXY: '127.0.0.1', no_proxy: '127.0.0.1' } })
const lock = JSON.parse(fs.readFileSync(path.join(directory, 'package-lock.json'), 'utf8'))
assert.deepEqual(JSON.parse(fs.readFileSync(path.join(directory, 'package.json'), 'utf8')), manifest)
console.log(`NPM_CANDIDATE=${lock.packages['node_modules/clock-dependency'].version}`)
} catch (error) {
console.error(error)
process.exitCode = 1
} finally {
server.close()
fs.rmSync(directory, { recursive: true, force: true })
}
})

-- input.yaml --
recorded-at: 2026-10-02T06:10:32Z
job:
package-manager: npm_and_yarn
source:
provider: github
repo: dependabot/smoke-tests
directory: /
commit: aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa

-- later.yaml --
recorded-at: 2026-10-07T06:10:32Z
job:
package-manager: npm_and_yarn
source:
provider: github
repo: dependabot/smoke-tests
directory: /
commit: aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
Loading