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
21 changes: 19 additions & 2 deletions .devcontainer/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,8 @@
It also declares the optional `github_token` build secret (from the
`GITHUB_TOKEN` environment variable, which CI exports) that
authenticates `../scripts/develop.sh`'s downloads; unset locally, it is
simply absent.
simply absent. Its `environment:` sets `CLAUDE_CONFIG_DIR` to the
`claude-config` volume's mount path (see "Do").
- `compose.instance.yml` — instance-owned stub (`ignore` tier, never
touched by sync): an instance's `include:` list and extra `services.myapp`
settings. See `docs/TEMPLATE.md`'s "Instance extension points".
Expand Down Expand Up @@ -155,14 +156,30 @@ need the window reconnected, not a fresh image.
not by Renovate: Renovate can't regenerate this file, so
`../.github/renovate.json` turns its devcontainer feature updates
off rather than let them merge with a stale lockfile.
- Change `CLAUDE_CONFIG_DIR` (`compose.yml`'s `services.myapp`
`environment:`), `claude-config`'s mount path and the
`/home/vscode/.claude` paths in `../scripts/develop.sh` together.
develop.sh creates that directory owned by `vscode` at mode 0700, so a
fresh volume starts out writable by Claude Code. The
variable points Claude Code at the volume for all its state. Without
it, Claude Code keeps `.claude.json` (account, MCP servers,
per-project trust) at `~/.claude.json`, outside the volume, so a
rebuild loses it and first-launch setup runs again. See Claude Code's
[dev container docs](https://code.claude.com/docs/en/devcontainer#persist-authentication-and-settings-across-rebuilds).
The variable, or a new mount path, takes effect only on a full
rebuild, and that first rebuild still starts without `.claude.json`.
To keep it, quit Claude Code and run
`cp ~/.claude.json ~/.claude/.claude.json` (into the volume) as the
last step before rebuilding, or sign in once more afterwards.

## Don't

- Bind- or volume-mount a language runtime's dependency tree, or anything
else you don't want a host antivirus/DLP tool scanning or syncing — it
lives purely in the container's writable layer instead. (`claude-config`
and `gh-config` are deliberate, narrow exceptions — small login and
session state; see `compose.yml`'s comments on those volumes.)
session state, including Claude Code's `.claude.json`; see
`compose.yml`'s comments on those volumes.)
- Add a `ports:` mapping or a `networks:` block anywhere here.
- Pin a feature or image version as `latest` — pin the exact version so
Renovate can bump it deliberately.
14 changes: 11 additions & 3 deletions .devcontainer/compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,13 @@ services:
# adds one.
- claude-config:/home/vscode/.claude
- gh-config:/home/vscode/.config/gh
environment:
# Keeps all Claude Code state, .claude.json included, in the
# claude-config volume. Must match its mount path above and the
# paths in scripts/develop.sh; see .devcontainer/README.md's "Do".
# Set here rather than in devcontainer.json's remoteEnv so every
# process in the container sees it, not just VS Code's.
CLAUDE_CONFIG_DIR: /home/vscode/.claude
command: sleep infinity
# No ports: mapping and no networks: block — see .devcontainer/
# README.md's "Don't". An instance that needs host access adds
Expand All @@ -39,9 +46,10 @@ services:

volumes:
# Persists Claude Code CLI session/credential state across container
# rebuilds. A small amount of session-state data, not a language
# runtime's dependency tree, so the DLP/malware-scanning concern above
# doesn't apply the same way here.
# rebuilds, .claude.json included (see CLAUDE_CONFIG_DIR above). A
# small amount of session-state data, not a language runtime's
# dependency tree, so the DLP/malware-scanning concern above doesn't
# apply the same way here.
claude-config:
# Persists the GitHub CLI's login (`gh auth login`) across rebuilds, for
# the same reason.
Expand Down
2 changes: 1 addition & 1 deletion .github/template-sync-state.json
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
{
"template_repo": "craftainer/template-base",
"synced_tag": "v1.11.4"
"synced_tag": "v1.11.6"
}
27 changes: 20 additions & 7 deletions .github/workflows/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -256,9 +256,15 @@ workflow works with no registry configured at all. When it is set:
- `OCI_IMAGE_NAME` (variable, optional) — the image path within the
registry, e.g. `myorg/template-fastapi`. Defaults to the repository's
own `owner/repo` (lowercased) if unset.
- `OCI_REGISTRY_USERNAME` / `OCI_REGISTRY_PASSWORD` (secrets,
required whenever `OCI_REGISTRY` is set) — credentials for
`docker/login-action`.
- `OCI_REGISTRY_USERNAME` / `OCI_REGISTRY_PASSWORD` (secrets) —
credentials for `docker/login-action`. Required whenever
`OCI_REGISTRY` is set, except to push to the repository's own GHCR
packages (`ghcr.io/<owner>/<repo>...`): the `build` and `release`
jobs' `GITHUB_TOKEN` has `packages: write`, so the instance's
`publish` and `finalize` can log in with `GITHUB_ACTOR`/`GITHUB_TOKEN` instead (see
`../../docs/TEMPLATE.md`'s "Release: a Makefile contract"). The
contract asks an instance's `publish` to prefer the secrets when they
are set; they remain the way to reach any other registry.

## Image labels

Expand Down Expand Up @@ -286,7 +292,10 @@ cleanly when the registry isn't configured.
- `OCI_REGISTRY` (variable) — registry host (e.g. `ghcr.io`).
- `OCI_IMAGE_NAME` (variable, optional) — image path within the registry.
- `OCI_REGISTRY_USERNAME` / `OCI_REGISTRY_PASSWORD` (secrets) —
registry credentials.
registry credentials. Optional for the repository's own GHCR
packages, which the `build` and `release` jobs' `GITHUB_TOKEN`
(`packages: write`, actor `GITHUB_ACTOR`) can push to from `publish`
and `finalize`.
- `CI_RUNNER_AMD64` / `CI_RUNNER_ARM64` (variables, optional) — runner
labels for each arch leg, defaulting to `ubuntu-24.04` /
`ubuntu-24.04-arm`.
Expand All @@ -311,9 +320,13 @@ cleanly when the registry isn't configured.

## Do

- Give `release.yml` and `template-sync.yml` `contents: write` and
nothing broader (`template-sync.yml` also needs `pull-requests:
write`, for nothing else); leave `checks.yml` at `contents: read`.
- Give `release.yml` and `template-sync.yml` `contents: write`, with
exactly two additions and nothing else broader: `template-sync.yml`
also needs `pull-requests: write`, and `release.yml`'s `build` and
`release` jobs also have `packages: write`, so `make publish` and
`make finalize` can push to the repository's own GHCR packages with
`GITHUB_TOKEN` (no other job has it; the token is in `build`'s job
env, so every per-arch target can use it). Leave `checks.yml` at `contents: read`.
- Keep `id-token: write` on `release.yml`'s `publish-packages` job only
(PyPI, npm and crates.io trusted publishing via GitHub OIDC). GitHub
permissions are per job, not per step, and that job's `if:` on the
Expand Down
15 changes: 13 additions & 2 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -80,10 +80,15 @@ jobs:
arch: ${{ fromJSON(needs.version.outputs.arches) }}
# Deliberately no `id-token: write` and no package-registry secrets
# here: this job runs for every instance, once per arch, and image
# publishing needs neither. Package publishing is the separate
# `publish-packages` job below.
# publishing needs neither. Package publishing (PyPI, npm, crates.io)
# is the separate `publish-packages` job below. `packages: write` lets
# `make publish` push to this repository's own GHCR packages
# (ghcr.io/<owner>/<repo>...) with `GITHUB_TOKEN` (actor
# `GITHUB_ACTOR`) when the OCI_REGISTRY_* secrets are unset. The
# `release` job has it too, for `make finalize`'s manifest lists.
permissions:
contents: write
packages: write
# renovate: datasource=github-runners depName=ubuntu
runs-on: ${{ matrix.arch == 'arm64' && (vars.CI_RUNNER_ARM64 || 'ubuntu-24.04-arm') || (vars.CI_RUNNER_AMD64 || 'ubuntu-24.04') }}
env:
Expand Down Expand Up @@ -248,6 +253,12 @@ jobs:
concurrency:
group: release-${{ needs.version.outputs.tag }}
cancel-in-progress: false
# `packages: write` lets `make finalize` combine per-arch tags into a
# multi-arch manifest list in this repository's own GHCR packages with
# `GITHUB_TOKEN`, as `build`'s `make publish` pushes them.
permissions:
contents: write
packages: write
# renovate: datasource=github-runners depName=ubuntu
runs-on: ${{ vars.CI_RUNNER || 'ubuntu-24.04' }}
steps:
Expand Down
21 changes: 19 additions & 2 deletions docs/TEMPLATE.md
Original file line number Diff line number Diff line change
Expand Up @@ -135,9 +135,9 @@ unchanged across every instance), then drives these targets:
| Target | Runs | Where | Env available |
|---|---|---|---|
| `release-arches` | once, first | host runner | none; must print a JSON array on stdout, e.g. `["amd64"]` |
| `build`, `sbom`, `release-assets`, `publish` | once **per arch**, in that order | host runner for that arch | `RELEASE_VERSION`, `RELEASE_TAG`, `RELEASE_ARCH`, `OCI_REGISTRY`, `OCI_IMAGE_NAME`, `OCI_REGISTRY_USERNAME`, `OCI_REGISTRY_PASSWORD`, all `GITHUB_*`; no package-registry variables or secrets, and no OIDC token |
| `build`, `sbom`, `release-assets`, `publish` | once **per arch**, in that order | host runner for that arch | `RELEASE_VERSION`, `RELEASE_TAG`, `RELEASE_ARCH`, `OCI_REGISTRY`, `OCI_IMAGE_NAME`, `OCI_REGISTRY_USERNAME`, `OCI_REGISTRY_PASSWORD`, all `GITHUB_*` (`GITHUB_TOKEN` has `packages: write`); no package-registry variables or secrets, and no OIDC token |
| `release-check` | once, after all arches; gates the GitHub release and `publish-packages` (not the per-arch image `publish`, which already ran in `build`) | **inside the devcontainer** (`devcontainers/ci`), stack up | `RELEASE_VERSION`, `RELEASE_TAG`, `GITHUB_REPOSITORY`; **not** `RELEASE_ARCH` (it runs once, not per arch) and no `ARCHES`: to check each arch's files in `dist/`, iterate over `make -s release-arches` |
| `finalize` | once, after `release-check`; then the GitHub release is created | host runner | same as per-arch minus `RELEASE_ARCH` |
| `finalize` | once, after `release-check`; then the GitHub release is created | host runner | same as per-arch minus `RELEASE_ARCH` (`GITHUB_TOKEN` also has `packages: write`) |
| `publish-packages` | once, after the GitHub release exists, **only if** `PYPI_PUBLISH`, `NPM_PUBLISH` or `CARGO_PUBLISH` is set | host runner (own job, with `id-token: write`) | `RELEASE_VERSION`, `RELEASE_TAG`, `PYPI_PUBLISH`, `NPM_PUBLISH`, `CARGO_PUBLISH`, all `GITHUB_*`, the OIDC token, `NPM_TOKEN` and `CARGO_REGISTRY_TOKEN` (bootstrap secrets; empty when unset, so treat empty as absent); `dist/` holds every arch leg's files |
| `verify-published` | once, after `publish-packages` succeeded, **only if** a `*_PUBLISH` variable is set | host runner (own job, no secrets, no OIDC token) | `RELEASE_VERSION`, `RELEASE_TAG`, `PYPI_PUBLISH`, `NPM_PUBLISH`, `CARGO_PUBLISH`; `dist/` as for `publish-packages` |

Expand All @@ -164,6 +164,23 @@ multi-arch manifest list. `RELEASE_VERSION` (e.g. `1.2.3` or
embedding or tagging with the version. A single-arch no-op is the
default: `release-arches` prints `["amd64"]`.

**Registry credentials for `publish` and `finalize`.** The per-arch job's
and the `release` job's `GITHUB_TOKEN` have `packages: write`, so an
instance's `publish` and `finalize` (e.g. a multi-arch manifest list) may
push to the repository's own GHCR packages (`ghcr.io/<owner>/<repo>...`) without
`OCI_REGISTRY_USERNAME`/`OCI_REGISTRY_PASSWORD`: log in with username
`GITHUB_ACTOR` and password `GITHUB_TOKEN`. A package that the workflow
creates inherits the repository's permissions. A package that already
exists and isn't linked to the repository first needs the repository added
under the package's "Manage Actions access" settings, with write access.
The explicit secrets remain the way to reach any other registry, or a
package outside that repository's reach, and an instance that sets them
should prefer them. Base's own `publish` and `finalize` are no-ops and log
in nowhere, so each instance implements this fallback in its own
`Makefile`. Trade-off: the token is in the job env, so every per-arch
target (`build`, `sbom`, `release-assets`), not only `publish`, can write
those packages.

**Upgrading a package instance.** Before this contract, `publish` also
uploaded packages, once per arch. Now `publish` is image-only and gets no
`*_PUBLISH` variables, secrets or OIDC token. An instance that publishes
Expand Down
10 changes: 9 additions & 1 deletion scripts/develop.sh
Original file line number Diff line number Diff line change
Expand Up @@ -175,8 +175,11 @@ run_prek_installer() {
env HOME=/home/vscode INSTALLER_NO_MODIFY_PATH=1 sh "$installer_script"
}

# CLAUDE_CONFIG_DIR matches compose.yml's, so anything the installer writes
# lands where Claude Code will look for it at runtime.
run_claude_installer() {
sudo -u vscode env HOME=/home/vscode bash "$installer_script" "$claude_code_version"
sudo -u vscode env HOME=/home/vscode CLAUDE_CONFIG_DIR=/home/vscode/.claude \
bash "$installer_script" "$claude_code_version"
}

# xz-utils: Node's own release tarballs are .tar.xz.
Expand Down Expand Up @@ -240,6 +243,11 @@ download_installer prek "https://github.com/j178/prek/releases/download/v${prek_
retry run_prek_installer
ln -s /home/vscode/.local/bin/prek /usr/local/bin/prek

# Created here, owned by vscode, because a fresh claude-config volume
# (compose.yml) copies this directory's ownership and mode on first mount;
# left to the installer it could be missing or root-owned. 0700: it holds
# Claude Code's credentials.
sudo -u vscode install -d -m 0700 /home/vscode/.claude
download_installer claude https://claude.ai/install.sh
retry run_claude_installer
ln -s /home/vscode/.local/bin/claude /usr/local/bin/claude
Expand Down