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
20 changes: 20 additions & 0 deletions docs/contributing/release.md
Original file line number Diff line number Diff line change
Expand Up @@ -428,6 +428,26 @@ comment: the workflows hold publish rights, and a re-tagged action is code
they would run. Dependabot moves all of these pins on the same weekly schedule
as the npm dependencies, so the pinning costs review, not staleness.

That last clause is what bounds where a pin belongs. The `docker` and
`docker-compose` ecosystems in `.github/dependabot.yml` are scoped to
`docker/`, so a digest written by hand anywhere else is a digest nothing ever
moves — pinning costing staleness rather than review, which is the trade this
section exists to avoid. Where something outside `docker/` needs one of these
images, it therefore *reads* the pinned value instead of repeating it:
`scripts/board-eject-smoke.mts` takes the `psql` client it shells out to from
`docker/compose.yml`'s `postgres` service, through `pinnedComposeImage`
(`scripts/compose-images.mts`), so the smoke runs a digest and still follows
the weekly bump without carrying a second copy of it.

The throwaway Postgres that GitHub Actions starts as a job's `services:`
container is the deliberate exception, and stays on the bare
`postgres:18-alpine` tag. It is created empty for one job and discarded with
it, nothing it holds outlives the run, and no Dependabot ecosystem reads a
workflow's `services:` block — so a digest there would rot in place while
buying nothing. A bare tag in a `services:` block is a decision, not an
oversight; a bare tag in a Dockerfile, a compose file, or a script that reads
one is the bug.

## One-time setup

### The deploy key the cut workflow pushes with
Expand Down
15 changes: 4 additions & 11 deletions scripts/board-eject-smoke.mts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { tmpdir } from 'node:os'
import { basename, join } from 'node:path'

import { assertBoardAssetsServe } from './board-smoke-assets.mts'
import { pinnedComposeImage } from './compose-images.mts'
import { packClosure } from './pack-workspace-closure.mts'
import { ROOT } from './workspace-packages.mjs'

Expand All @@ -30,20 +31,12 @@ function run(command: string, args: readonly string[], cwd: string, env?: NodeJS
}
}

const PSQL_IMAGE = await pinnedComposeImage('postgres')

function psql(sql: string): string {
const result = spawnSync(
'docker',
[
'run',
'--rm',
'--network',
'host',
'postgres:18-alpine',
'psql',
DATABASE_URL as string,
'-tAc',
sql,
],
['run', '--rm', '--network', 'host', PSQL_IMAGE, 'psql', DATABASE_URL as string, '-tAc', sql],
{ encoding: 'utf8' },
)
if (result.status !== 0) {
Expand Down
26 changes: 26 additions & 0 deletions scripts/compose-images.mts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { readFile } from 'node:fs/promises'
import { join } from 'node:path'

import { ROOT } from './workspace-packages.mjs'

const COMPOSE_FILE = 'docker/compose.yml'

const PINNED = /^\s{4}image:\s*(\S+@sha256:[0-9a-f]{64})\s*$/m

export async function pinnedComposeImage(service: string): Promise<string> {
const source = await readFile(join(ROOT, COMPOSE_FILE), 'utf8')
const block = new RegExp(`^ {2}${service}:\\n(?: {4}.*\\n|\\n)*`, 'm').exec(source)
if (block === null) {
throw new Error(`compose-images: ${COMPOSE_FILE} declares no "${service}" service`)
}

const match = PINNED.exec(block[0])
if (match === null) {
throw new Error(
`compose-images: ${COMPOSE_FILE}'s "${service}" service carries no digest-pinned image. ` +
'Every base image there is pinned by digest — see docs/contributing/release.md, ' +
'"Deploys are deterministic, and that is load-bearing".',
)
}
return match[1] as string
}
25 changes: 25 additions & 0 deletions scripts/compose-images.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { readFile } from 'node:fs/promises'
import { join } from 'node:path'

import { describe, expect, it } from 'vitest'

import { pinnedComposeImage } from './compose-images.mts'
import { ROOT } from './workspace-packages.mjs'

describe('pinnedComposeImage', () => {
it("returns the postgres service's digest-pinned reference", async () => {
const image = await pinnedComposeImage('postgres')

expect(image).toMatch(/^postgres:[^@]+@sha256:[0-9a-f]{64}$/)
})

it('returns the very string docker/compose.yml carries, so the two can never drift', async () => {
const compose = await readFile(join(ROOT, 'docker/compose.yml'), 'utf8')

expect(compose).toContain(`image: ${await pinnedComposeImage('postgres')}`)
})

it('refuses a service the compose file does not declare', async () => {
await expect(pinnedComposeImage('nonexistent')).rejects.toThrow(/declares no "nonexistent"/)
})
})