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
2 changes: 1 addition & 1 deletion .claude/agents/reviewer.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ Review against, in rough order of severity:
with nothing checking the effect.
6. **Consumer contract.** A removed or renamed option, or a changed default that alters
what an existing consumer already gets, without `BREAKING CHANGE:` and a migration
note. Consumers pin `:1`, so a minor or patch reaches them automatically — an
note. Consumers pin `:2`, so a minor or patch reaches them automatically — an
unannounced behaviour change is the expensive mistake here.
7. **Hand-edited `version`** in `devcontainer-feature.json`, which semantic-release owns
and will either overwrite or collide with.
Expand Down
19 changes: 9 additions & 10 deletions .github/dependabot.yml
Original file line number Diff line number Diff line change
Expand Up @@ -24,17 +24,16 @@ updates:
patterns:
- "*"

# Tracks the features listed in .devcontainer/devcontainer.json — Node,
# docker-in-docker, github-cli, claude-code, and `devbase` itself, which that config
# consumes from the registry like any other consumer.
# Tracks the features listed in .devcontainer/devcontainer.json — docker-in-docker and
# `devbase` itself, which that config consumes from the registry like any other
# consumer — and keeps the committed devcontainer-lock.json up to date.
#
# All of them are pinned to a major tag (`node:2`, `devbase:1`), so there is nothing
# here for Dependabot to move until a new *major* appears: within `1.x` the tag itself
# resolves to the newest release on every rebuild. That is deliberate for `devbase` —
# this environment is a standing check that the published artifact works, and pinning it
# to a digest would freeze that check on a stale version. A digest pin is what a
# consuming repository wants, and consuming repositories have a devcontainer-lock.json
# for it; this one does not, on purpose.
# Both are pinned to a major tag (`docker-in-docker:2`, `devbase:2`). Dependabot does
# not change a tag, so a new *major* stays a manual edit to devcontainer.json. It does
# re-resolve the lock within the tag, which is what moves the digest here: this
# repository commits devcontainer-lock.json (in .devcontainer/ and
# .devcontainer/local/), so a rebuild uses the locked digest, and this entry is what
# keeps that dogfooding check from freezing on a stale release.
- package-ecosystem: "devcontainers"
directory: "/"
schedule:
Expand Down
42 changes: 21 additions & 21 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ commitizen, pnpm, the `gh` credential helper (and a persisted `gh` login), persi
installation and the on-attach toolchain banner.

- **Language:** shell (bash for build-time, zsh for the lifecycle hooks) plus JSON metadata
- **Published coordinate:** `ghcr.io/centralnicgroup-opensource/rtldev-middleware-devcontainer-features/devbase:1`
- **Published coordinate:** `ghcr.io/centralnicgroup-opensource/rtldev-middleware-devcontainer-features/devbase:2`
- **Default branch:** `main`

There is **no application code here.** Every change is to shell, container metadata, or
Expand Down Expand Up @@ -234,21 +234,20 @@ artifact works.
of `pnpm lint`: the copy is gitignored and absent in CI.
- `.devcontainer/env-info.conf` is also the reference example of that file: a change to the
config format breaks here first.
- **Both `devcontainer-lock.json` files are committed**, and that is a reversal — this
used to ship without one so each rebuild resolved the newest `1.x`. The lock pins digests
instead, so a rebuild no longer picks up a fresh release on its own — and **nothing else
picks it up either.** `devcontainer upgrade` only fills in _missing_ entries: an entry
that still satisfies `:1` is re-emitted unchanged, which `1.2.0` does. Dependabot's
`devcontainers` ecosystem (weekly, `/`) has never opened a PR here either — it bumps
version references in `devcontainer.json`, and this frame references the moving tag
`devbase:1`, which never needs bumping. The updater itself works: it raises PRs for
`github-actions` and `npm`. So the lock sat at `1.2.0` across the `1.2.1`, `1.2.2`,
`1.3.0` and `1.4.0` releases, and the dogfooding check it was supposed to trigger did
not happen. Moving it means deleting the entry (or the whole file) and re-resolving, then
`prettier --write` on the result because the CLI writes it with no trailing newline and
`pnpm lint` rejects that — see the README. **Do not read a green rebuild as proof the
newest release works**, and do not assume anything will move the pin for you: check which
digest the lock names, and move it by hand when the point is to test a release.
- **Both `devcontainer-lock.json` files are committed** (`.devcontainer/` and
`.devcontainer/local/`), and that is a reversal — this used to ship without one so each
rebuild resolved the newest `1.x`. The lock pins digests, so a rebuild does not pick up a
fresh release on its own. **Dependabot's `devcontainers` ecosystem (weekly, `/`) does move
it**: it re-resolves the lock within the tag the frame names, as the lock-only PR moving
`2.0.0` to `2.0.1` against `devbase:2` showed. What it never does is change the tag
(`:1` to `:2`), and `devcontainer upgrade` alone moves nothing, because it only fills in
_missing_ entries. Do not repeat the older claim that Dependabot never moves a lock; it
was wrong. To move it by hand (a tag move, or testing a release now),
delete the devbase entry with `jq`, run `pnpm dlx @devcontainers/cli upgrade
--workspace-folder .`, then `pnpm prettier:fix` because the CLI writes the file with no
trailing newline and `pnpm lint` rejects that — see the README. Never `npx`. **Do not
read a green rebuild as proof the newest release works**: check which digest the lock
names.
- **Never make this frame rely on a guarantee that has not been released yet.** Because it
consumes the registry, the frame is only ever as correct as the _published_ Feature. The
moment `github-cli` was dropped from the feature list here, on the strength of a
Expand Down Expand Up @@ -277,7 +276,7 @@ artifact works.
here: it has no bypass and would be rejected. (RSRMID-2994)
- **The commit type is the release decision.** `fix(devbase)` is a patch,
`feat(devbase)` a minor, either plus `BREAKING CHANGE:` a major; `ci`/`docs`/`chore`/
`test`/`refactor` publish nothing. Consumers pin `:1`, so a patch or minor reaches every
`test`/`refactor` publish nothing. Consumers pin `:2`, so a patch or minor reaches every
repository on its next rebuild while a major does not — from the consumer's side, a
removed or renamed option, or a changed default that alters what they already get, is a
major.
Expand All @@ -295,11 +294,12 @@ artifact works.
- **A gate over `needs.*.result` must test each result.** `grep -vw success` over the
joined line passes whenever _any_ job succeeded, which is the opposite of what a gate
is for.
- **Consumers pin `:1`**, so a breaking change inside `1.x` reaches them on their next
- **Consumers pin `:2`**, so a breaking change inside `2.x` reaches them on their next
rebuild — immediately for a consumer with no `devcontainer-lock.json`, since that
re-resolves the newest `1.x` every time. A consumer who commits a lock stays on the
pinned digest until someone re-resolves it by hand, which is the opposite failure and
just as easy to misread (see Dogfooding). Treat the major as a real contract either way.
re-resolves the newest `2.x` every time. A consumer who commits a lock stays on the
pinned digest until it is re-resolved — by Dependabot, if the repository configures the
`devcontainers` ecosystem, otherwise by hand — which is the opposite failure and just as
easy to misread (see Dogfooding). Treat the major as a real contract either way.

## Git Conventions

Expand Down
4 changes: 2 additions & 2 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ overwritten or collides with the next release.
What that means is that **your commit type decides what consumers get**, so pick it
deliberately:

| Commit | Result for a consumer pinned to `:1` |
| Commit | Result for a consumer pinned to `:2` |
| --------------------------------------- | ----------------------------------------------------- |
| `fix(devbase): …` | patch — picked up on their next rebuild |
| `feat(devbase): …` | minor — picked up on their next rebuild |
Expand All @@ -48,7 +48,7 @@ deliberately:

A removed or renamed option, or a changed default that alters what an existing consumer
already gets, is a `BREAKING CHANGE:` and needs a migration note in the README. Anything
inside `1.x` reaches every repository automatically, so the major is a real contract.
inside `2.x` reaches every repository automatically, so the major is a real contract.

And one rule that is yours, not the tooling's:

Expand Down
79 changes: 47 additions & 32 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -261,9 +261,10 @@ A stack elaborate enough to own its own setup turns the generic part off:

Two things worth knowing about consuming it:

- **Pin the major (`:1`), not a patch.** A new `1.x` is picked up on the next rebuild,
- **Pin the major (`:2`), not a patch.** A new `2.x` is picked up on the next rebuild,
and the digest recorded in `devcontainer-lock.json` keeps the build reproducible in
between.
between. Dependabot keeps that lock current if you configure its `devcontainers`
ecosystem — see [Keeping consumers up to date](#keeping-consumers-up-to-date).
- **Lifecycle order.** The Feature's `postCreateCommand` and `postAttachCommand` run
_before_ the ones in your `devcontainer.json`, so your own hooks can rely on pnpm,
the global packages and your dependencies already being installed. Put
Expand Down Expand Up @@ -556,7 +557,7 @@ call is not path-filtered, so a push that touches nothing under `features/` is t
(`2.1.0` shipped with a bug because the release used to start without waiting for the
tests, which only ran alongside it; RSRMID-2982.)

| Commit | Result for a consumer pinned to `:1` |
| Commit | Result for a consumer pinned to `:2` |
| --------------------------------------- | ----------------------------------------------------- |
| `fix(devbase): …` | patch — picked up on their next rebuild |
| `feat(devbase): …` | minor — picked up on their next rebuild |
Expand Down Expand Up @@ -607,10 +608,11 @@ rebuild — the upgrade is deliberate, in two steps:

1. Change the reference in `.devcontainer/devcontainer.json` from `devbase:1` to
`devbase:2`.
2. If the repository commits a `devcontainer-lock.json`, re-resolve it. Neither
`devcontainer upgrade` nor Dependabot will do this for you — see
[Keeping consumers up to date](#keeping-consumers-up-to-date) for why, and for the
commands.
2. If the repository commits a `devcontainer-lock.json`, re-resolve it. Dependabot moves
a lock within the tag it already names, but it never changes the tag itself, so the
`:1` to `:2` edit in step 1 is yours to make, and so is dropping the old lock entry
and re-resolving — see [Keeping consumers up to date](#keeping-consumers-up-to-date)
for the commands.

**What you do _not_ lose.** `gh`, `node` at `lts`, `pnpm` and the RTK binary are all
unchanged, as is the `anthropic.claude-code` VS Code extension — which is now the only
Expand All @@ -636,40 +638,53 @@ it brings the ownership problem above back with it.
**A consumer with no `devcontainer-lock.json` needs nothing.** Every rebuild re-resolves
`devbase:2` to the newest `2.x`, so a release reaches them by rebuilding — a **minor or
patch** release, that is; a major changes the tag and is the deliberate step described in
[Migrating to 2.0.0](#migrating-to-200). That is the
propagation story the copied-files approach never had — and for most consumers it is the
whole story.
[Migrating to 2.0.0](#migrating-to-200). That is the propagation story the copied-files
approach never had — and for most consumers it is the whole story.

**A consumer that commits a lock stays on the pinned digest until something re-resolves
it, and that something is Dependabot, if the repository asks for it.** Add the
`devcontainers` ecosystem to `.github/dependabot.yml`:

```yaml
- package-ecosystem: "devcontainers"
directory: "/"
schedule:
interval: "weekly"
groups:
devcontainers:
patterns:
- "*"
```

**A consumer that commits a lock has to move the pin deliberately**, and neither of the
two mechanisms you would expect does it for them:
Dependabot then re-resolves `devcontainer-lock.json` and opens a PR when a newer release
satisfies the tag the frame already names. The PR is a lock-only change, and it covers
every Feature in the file, not just `devbase`. Two PRs show it: a lock-only move of `devbase`
`1.3.0` to `1.5.0` against `devbase:1` in the PHP SDK, and `2.0.0` to `2.0.1` against
`devbase:2` in this repository. Without that entry nothing moves the pin, and the lock
sits still at whatever digest was last resolved.

- `devcontainer upgrade` only fills in _missing_ entries. An entry that still satisfies
its reference is re-emitted unchanged, and `1.2.0` satisfies `:1` just as well as
`1.4.0` does, so the command is a no-op for exactly the case you want it for.
- Dependabot's `devcontainers` ecosystem bumps **version references in
`devcontainer.json`**. A frame that references a moving major tag such as `devbase:2`
has no reference to bump, so no PR is ever raised. This repository has that ecosystem
configured and has never received a devcontainers PR, while receiving them for
`github-actions` and `npm` — the updater works, it simply has nothing to say about `:1`.
What Dependabot does **not** do is change the tag. Moving from `devbase:1` to `devbase:2`
stays a deliberate edit to `devcontainer.json`, followed by a manual re-resolve (below).

The result is a lock that silently stays put: this repository's own pinned `1.2.0` through
four releases. To move it, drop the entry and re-resolve:
`devcontainer upgrade` alone is not a way to move a pin: it only fills in _missing_
entries, and an entry that still satisfies its reference is re-emitted unchanged — `1.2.0`
satisfies `:1` just as well as `1.4.0` does. Deleting the entry first is the step that
matters. To re-resolve by hand, for a tag move or to test a release right now:

```sh
jq 'del(.features["ghcr.io/centralnicgroup-opensource/rtldev-middleware-devcontainer-features/devbase:2"])' \
.devcontainer/devcontainer-lock.json > /tmp/lock && mv /tmp/lock .devcontainer/devcontainer-lock.json
npx devcontainer upgrade --workspace-folder .
npx prettier --write .devcontainer/devcontainer-lock.json
.devcontainer/devcontainer-lock.json > /tmp/lock.json && cp /tmp/lock.json .devcontainer/devcontainer-lock.json
pnpm dlx @devcontainers/cli upgrade --workspace-folder .
```

Deleting the whole lock instead re-resolves every feature, which is fine but a wider diff.
The `prettier` line is not optional: the CLI writes the lock with no trailing newline and
`pnpm lint` rejects it.
Then format the file with your repository's formatter (e.g. `pnpm prettier:fix` where that
script exists); the CLI writes the lock with no trailing newline.

**If you want Dependabot to do this for you, reference a precise version** —
`devbase:1.4.0` rather than `devbase:1` — and accept a PR per release instead of a moving
major. That is a real trade: the moving tag is what makes a patch reach every repository
without ceremony.
Other entries in the lock may move within their own ranges in the same pass, so read the
diff. Deleting the whole lock instead re-resolves every Feature, which is a wider diff
for the same result. The formatting step is not optional: the CLI writes the lock with no
trailing newline and `pnpm lint` rejects it. (Use `pnpm dlx`, never `npx`: this
repository installs with pnpm.)

## Troubleshooting

Expand Down
Loading