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
121 changes: 121 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
name: CI

on:
push:
branches:
- main
pull_request:
workflow_dispatch:

permissions: {}

concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}

env:
# renovate: datasource=npm depName=@devcontainers/cli
DEVCONTAINER_CLI_VERSION: "0.88.0"

jobs:
discover:
name: Discover features
runs-on: ubuntu-latest
permissions:
contents: read
outputs:
features: ${{ steps.discover.outputs.features }}
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false

- name: List features under src/
id: discover
run: |
set -euo pipefail
features="$(find src -mindepth 2 -maxdepth 2 -name devcontainer-feature.json -printf '%h\n' \
| xargs -r -n1 basename \
| sort \
| jq -R -s -c 'split("\n") | map(select(length > 0))')"

if [ "${features}" = "[]" ]; then
echo "::error::No features found under src/"
exit 1
fi

echo "features=${features}" >> "${GITHUB_OUTPUT}"
echo "Discovered features: ${features}"

smoke-test:
name: ${{ matrix.feature }} on ${{ matrix.image.label }}
needs: discover
runs-on: ubuntu-latest
permissions:
contents: read
strategy:
fail-fast: false
matrix:
feature: ${{ fromJSON(needs.discover.outputs.features) }}
image:
- ref: mcr.microsoft.com/devcontainers/base:debian
label: mcr/base:debian
- ref: ghcr.io/bare-devcontainer/debian:trixie
label: bare/debian:trixie
env:
FEATURE: ${{ matrix.feature }}
IMAGE: ${{ matrix.image.ref }}
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false

- name: Install the Dev Containers CLI
run: npm install --global "@devcontainers/cli@${DEVCONTAINER_CLI_VERSION}"

- name: Assemble the test workspace
env:
WORKSPACE: ${{ runner.temp }}/smoke
run: |
set -euo pipefail

if [ ! -f "src/${FEATURE}/smoke_test.sh" ]; then
echo "::error::src/${FEATURE}/smoke_test.sh is missing; every feature must ship one."
exit 1
fi

# The feature is copied next to the generated devcontainer.json so it
# can be referenced as a local path, which installs the working tree
# copy rather than a published release.
rm -rf "${WORKSPACE}"
mkdir -p "${WORKSPACE}/.devcontainer"
cp -R "src/${FEATURE}" "${WORKSPACE}/.devcontainer/${FEATURE}"

# The mount is spelled out rather than left to the CLI default so the
# path the smoke test runs from is known here.
jq -n \
--arg image "${IMAGE}" \
--arg feature "./${FEATURE}" \
--arg mount "source=${WORKSPACE},target=/workspaces/smoke,type=bind" \
'{
name: "smoke-test",
image: $image,
workspaceMount: $mount,
workspaceFolder: "/workspaces/smoke",
features: {($feature): {}}
}' \
> "${WORKSPACE}/.devcontainer/devcontainer.json"

jq . "${WORKSPACE}/.devcontainer/devcontainer.json"

- name: Start the Dev Container
env:
WORKSPACE: ${{ runner.temp }}/smoke
run: devcontainer up --workspace-folder "${WORKSPACE}" --remove-existing-container

- name: Run smoke_test.sh
env:
WORKSPACE: ${{ runner.temp }}/smoke
run: |
devcontainer exec --workspace-folder "${WORKSPACE}" \
bash "/workspaces/smoke/.devcontainer/${FEATURE}/smoke_test.sh"
91 changes: 91 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
name: Release

on:
pull_request:
paths:
- "src/**/devcontainer-feature.json"
- "src/**/NOTES.md"
- ".github/workflows/release.yml"
workflow_dispatch:

permissions: {}

concurrency:
group: ${{ github.workflow }}-${{ github.event_name == 'workflow_dispatch' && 'release' || github.ref }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}

jobs:
release:
name: Publish features
runs-on: ubuntu-latest
timeout-minutes: 30
permissions:
contents: read
packages: write
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false

- name: Publish features
uses: devcontainers/action@1082abd5d2bf3a11abccba70eef98df068277772 # v1.4.3
with:
publish-features: "${{ github.event_name == 'workflow_dispatch' && github.ref == 'refs/heads/main' }}"
base-path-to-features: "./src"
generate-docs: "true"
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

- name: Generate GitHub App Token
id: app-token
if: ${{ github.event_name == 'workflow_dispatch' && github.ref == 'refs/heads/main' }}
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0
with:
client-id: ${{ secrets.HOUSEKEEPER_CLIENT_ID }}
private-key: ${{ secrets.HOUSEKEEPER_PRIVATE_KEY }}

- name: Create PR for Documentation
if: ${{ github.event_name == 'workflow_dispatch' && github.ref == 'refs/heads/main' }}
env:
GITHUB_TOKEN: ${{ steps.app-token.outputs.token }}
run: |
set -euo pipefail

changed=$(git status --porcelain -- 'src/*/README.md' | awk '{print $2}')
if [ -z "$changed" ]; then
echo "No documentation changes."
exit 0
fi

OWNER="${GITHUB_REPOSITORY_OWNER}"
REPO="${GITHUB_REPOSITORY#*/}"
BRANCH="housekeeper/doc-update-${GITHUB_RUN_ID}"
MESSAGE="docs: update feature documentation"

gh api "repos/${OWNER}/${REPO}/git/refs" \
--method POST \
-F ref="refs/heads/${BRANCH}" \
-F sha="${GITHUB_SHA}"

# The contents API commits as the app, so the checkout needs no
# credentials and the commits are signed by GitHub.
while IFS= read -r file; do
existing_sha=$(gh api "repos/${OWNER}/${REPO}/contents/${file}?ref=${BRANCH}" \
--jq '.sha' 2>/dev/null || true)
args=(
--method PUT
-F message="${MESSAGE}"
-F content="$(base64 -w0 "$file")"
-F branch="${BRANCH}"
)
[ -n "$existing_sha" ] && args+=(-F sha="$existing_sha")
gh api "repos/${OWNER}/${REPO}/contents/${file}" "${args[@]}"
done <<< "$changed"

features=$(echo "$changed" | sed 's|src/\([^/]*\)/README\.md|\1|' | sort -u | sed 's/^/- /')

gh pr create \
--title "${MESSAGE}" \
--body "$(printf '## Updated Features\n\n%s' "${features}")" \
--head "${BRANCH}" \
--base main
90 changes: 90 additions & 0 deletions .github/workflows/update-material.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
name: Update Trusted Material

on:
pull_request:
paths:
- .github/workflows/update-material.yml
- scripts/update-material.sh
schedule:
- cron: "0 6 * * 3"
workflow_dispatch:

permissions: {}

concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}

jobs:
update:
name: Refresh trusted material
runs-on: ubuntu-latest
timeout-minutes: 10
permissions:
contents: read
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false

- name: Refresh trusted material
id: update
run: |
set -euo pipefail

changed="$(bash scripts/update-material.sh)"
echo "changed=${changed}" >> "${GITHUB_OUTPUT}"

- name: Generate GitHub App Token
id: app-token
if: steps.update.outputs.changed == 'true' && github.event_name != 'pull_request'
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0
with:
client-id: ${{ secrets.HOUSEKEEPER_CLIENT_ID }}
private-key: ${{ secrets.HOUSEKEEPER_PRIVATE_KEY }}

- name: Create PR for refreshed material
if: steps.update.outputs.changed == 'true' && github.event_name != 'pull_request'
env:
GH_TOKEN: ${{ steps.app-token.outputs.token }}
run: |
set -euo pipefail

OWNER="${GITHUB_REPOSITORY_OWNER}"
REPO="${GITHUB_REPOSITORY#*/}"
BRANCH="housekeeper/update-material"
MESSAGE="chore: update trusted material"

# A single long-lived branch, so a run that finds the same update
# again amends the open pull request instead of opening another one.
if ! gh api "repos/${OWNER}/${REPO}/git/ref/heads/${BRANCH}" > /dev/null 2>&1; then
gh api "repos/${OWNER}/${REPO}/git/refs" \
--method POST \
-f ref="refs/heads/${BRANCH}" \
-f sha="${GITHUB_SHA}"
fi

# The contents API commits as the app, so the checkout needs no
# credentials and the commits are signed by GitHub.
while IFS= read -r file; do
existing_sha="$(gh api "repos/${OWNER}/${REPO}/contents/${file}?ref=${BRANCH}" \
--jq '.sha' 2>/dev/null || true)"
args=(
--method PUT
-F message="${MESSAGE}"
-F content="$(base64 -w0 "${file}")"
-F branch="${BRANCH}"
)
if [ -n "${existing_sha}" ]; then
args+=(-F sha="${existing_sha}")
fi
gh api "repos/${OWNER}/${REPO}/contents/${file}" "${args[@]}"
done < <(git diff --name-only)

if [ -z "$(gh pr list --state open --head "${BRANCH}" --json number --jq '.[0]')" ]; then
gh pr create \
--title "${MESSAGE}" \
--body "Automated sync of trusted material." \
--head "${BRANCH}" \
--base main
fi
19 changes: 19 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,25 @@ This repository publishes dev container features for use with the [Dev Container
- `ci: pin action SHAs`
- `chore: update renovate config`

## Documentation

- A feature's `README.md` is generated: `devcontainer features generate-docs`
deletes it and writes it back from `devcontainer-feature.json` plus the
feature's `NOTES.md`. Never edit `README.md` by hand — put prose in
`NOTES.md`, which is inserted after the options table.

## Testing

- Every feature under `src/` ships a `smoke_test.sh` next to its
`devcontainer-feature.json`. The `CI` workflow's `smoke-test` job starts a Dev
Container per feature and base image, with the feature installed at its
default options, and runs that script inside it as the remote user.
- Smoke tests are self-contained: they run in a plain container without the
`devcontainer features test` helper library, so they must not source
`dev-container-features-test-lib`, and they exit non-zero when a check fails.
- Assert against `${HOME}` rather than a hard-coded home directory: the base
images under test use different remote users.

## GitHub Actions

- Pin every action to a full commit SHA with a `# vX.Y.Z` comment,
Expand Down
16 changes: 16 additions & 0 deletions renovate.jsonc
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,22 @@
"// renovate: datasource=(?<datasource>\\S+)( registryUrl=(?<registryUrl>\\S+))? depName=(?<depName>\\S+)( versioning=(?<versioning>\\S+))?( extractVersion=(?<extractVersion>\\S+))?\\s+\"[^\"]+\":\\s*\"(?<currentValue>[^@\"\\s]+)\""
],
"versioningTemplate": "{{#if versioning}}{{{versioning}}}{{else}}semver{{/if}}"
},
{
// Tool versions pinned as workflow `env:` entries, which no built-in
// manager inspects. Actions themselves are covered by the
// github-actions manager and need no comment.
"customType": "regex",
"managerFilePatterns": [
"/^\\.github/workflows/[^/]+\\.ya?ml$/"
],
"matchStrings": [
// A `# renovate:` comment bound to the quoted value of the YAML
// key on the following line,
// e.g. DEVCONTAINER_CLI_VERSION: "0.88.0"
"# renovate: datasource=(?<datasource>\\S+)( registryUrl=(?<registryUrl>\\S+))? depName=(?<depName>\\S+)( versioning=(?<versioning>\\S+))?( extractVersion=(?<extractVersion>\\S+))?\\s+[A-Za-z0-9_]+:\\s*\"(?<currentValue>[^\"\\s]+)\""
],
"versioningTemplate": "{{#if versioning}}{{{versioning}}}{{else}}semver{{/if}}"
}
]
}
40 changes: 40 additions & 0 deletions scripts/update-material.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
#!/usr/bin/env bash
#
# update-material.sh — refresh the trust material vendored in this repository,
# writing changed files in place in the working tree. Prints "true" or "false"
# to stdout depending on whether anything changed; per-file download and
# comparison progress goes to stderr. Performs no git or GitHub operations.
#
# Usage:
# update-material.sh
set -euo pipefail

# path: the file tracked in this repository, url: where upstream publishes it.
MATERIALS='[
{
"path": "src/claude-code/claude-code.asc",
"url": "https://downloads.claude.ai/keys/claude-code.asc"
}
]'

changed=false
while IFS=$'\t' read -r path url; do
echo "Downloading ${url} -> ${path}" >&2

# Downloaded to a temporary file first so a failed request cannot leave a
# truncated key behind in the working tree.
tmp=$(mktemp)
wget -q -T 30 -t 3 -O "$tmp" "$url"

if cmp -s "$tmp" "$path"; then
echo " unchanged" >&2
else
echo " changed" >&2
changed=true
fi

chmod 644 "$tmp"
mv "$tmp" "$path"
done < <(jq -r '.[] | [.path, .url] | @tsv' <<< "$MATERIALS")

echo "$changed"
Loading
Loading