Skip to content
Closed
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
80 changes: 80 additions & 0 deletions .github/workflows/schema-sync.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
name: Schema sync

on:
schedule:
- cron: '41 6 * * 1'
workflow_dispatch:

permissions: {}

jobs:
sync:
runs-on: ubuntu-latest
timeout-minutes: 15
# The pull request is opened with the housekeeper app's token, so the job's own token only needs
# to read the repository for the checkout.
permissions:
contents: read
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
- uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0
with:
go-version-file: go.mod

- name: Refresh vendored schemas from upstream
run: make update-schemas

- name: Generate GitHub App Token
id: app-token
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 Schema Update
env:
GITHUB_TOKEN: ${{ steps.app-token.outputs.token }}
run: |
set -e

changed=$(git status --porcelain -- 'schema/data' | awk '{print $2}')
if [ -z "$changed" ]; then
echo "Vendored schemas are up to date."
exit 0
fi

OWNER="${GITHUB_REPOSITORY_OWNER}"
REPO="${GITHUB_REPOSITORY#*/}"
BRANCH="housekeeper/schema-update-${GITHUB_RUN_ID}"
MESSAGE="chore: update vendored Dev Container schemas"

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

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"

# The upstream commits the schemas were vendored from, so the review shows what moved.
spec=$(jq -r '.spec' schema/data/REVISIONS.json)
vscode=$(jq -r '.vscode' schema/data/REVISIONS.json)
files=$(echo "$changed" | sed 's|^schema/data/|- |')

gh pr create \
--title "${MESSAGE}" \
--body "$(printf '## Updated Schemas\n\n%s\n\n## Upstream Revisions\n\n- devcontainers/spec@%s\n- microsoft/vscode@%s\n' "${files}" "${spec}" "${vscode}")" \
--head "${BRANCH}" \
--base main
4 changes: 4 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,10 @@ coverage: ## Run tests and open an HTML coverage report
lint: ## Run all lint rules
go run github.com/golangci/golangci-lint/v2/cmd/golangci-lint@$(GOLANGCI_LINT_VERSION) run

.PHONY: update-schemas
update-schemas: ## Refresh the vendored Dev Container schemas from upstream
go run ./cmd/updateschemas

.PHONY: clean
clean: ## Remove build artifacts
rm -rf bin coverage.out coverage.html
Expand Down
40 changes: 37 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -67,13 +67,14 @@ With no arguments, the current directory is linted.
### Configuration

Every linting setting can be declared in the [config file](#config-file).
The four scalar settings additionally have a command-line flag, which
The five scalar settings additionally have a command-line flag, which
overrides the config file when given:

| Setting | Config member | Flag |
| --- | --- | --- |
| [Target platforms](#target-platforms) | `platforms` | `-platform` |
| [Merge features](#merging) | `merge` | `-merge` |
| [Schema validation](#schema-validation) | `schema` | `-schema` |
| [Deny warnings](#exit-codes) | `denyWarnings` | `-deny-warnings` |
| [Output format](#output-formats) | `format` | `-format` |
| [Category severities](#rule-categories) | `categories` | — |
Expand Down Expand Up @@ -164,6 +165,39 @@ A few limits apply:
- Registries are accessed anonymously, so a private image that cannot be
pulled that way counts as a fetch failure.

### Schema validation

Alongside the rules, decolint validates each file against the official
[Dev Container JSON Schema](https://containers.dev/implementors/json_reference/):
`devcontainer.json` and Feature (`devcontainer-feature.json`) files are
checked for the things the rules do not — misspelled or unknown property
names, wrong value types, and out-of-range enum values:

```
.devcontainer/devcontainer.json:8:3: error: unknown property "forwardPort"; did you mean "forwardPorts"? (schema-validation)
```

Schema findings are always reported as errors under the rule ID
`schema-validation`. They are not affected by category or rule severity
overrides, and [ignore comments](#suppressing-findings) do not suppress
them; choose the variant, or turn validation off entirely, instead:

- `main` (default) — validates `devcontainer.json` against the schema
including the Visual Studio Code and GitHub Codespaces extensions, so
their properties (such as `customizations.vscode`) are accepted.
- `base` — validates against the specification's base schema only, so
editor-specific properties are reported as unknown.
- `off` — disables schema validation.

```console
decolint -schema=base
```

The file is validated as written, before any [merging](#merging) or
variable substitution, so `${...}` placeholders in string values never
trip it. Templates are not schema-validated, but the `devcontainer.json`
a template ships is.

### Output formats

By default, findings are printed one per line, with the rule's
Expand Down Expand Up @@ -252,8 +286,8 @@ interpolation reads (note the differing [syntax](#merging)):
```

The remaining members mirror their flags: `platforms`, `merge`,
`denyWarnings`, and `format` (see the [Configuration](#configuration)
table).
`schema`, `denyWarnings`, and `format` (see the
[Configuration](#configuration) table).

For the strictest configuration, enable every category:

Expand Down
19 changes: 17 additions & 2 deletions cmd/decolint/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,10 @@ type Config struct {
// Format selects how lint issues are written to stdout: "text" (the default when empty), "json",
// "github", or "sarif". The -format flag takes precedence.
Format string `json:"format"`
// Schema selects the schema-validation variant: "main" (the default when empty), "base", or
// "off". "main" allows the VS Code and Codespaces extensions to devcontainer.json, "base"
// rejects them, and "off" disables schema validation. The -schema flag takes precedence.
Schema string `json:"schema"`
// LocalEnv maps names to the values "${localEnv:NAME}" (and "${env:NAME}") resolve to during
// variable substitution, which runs with Merge; see [substitute.Context.LocalEnv]. It is also
// the environment Compose-file "${...}" interpolation reads. Host environment variables are
Expand Down Expand Up @@ -78,6 +82,14 @@ func (cfg Config) MarshalJSONTo(enc *jsontext.Encoder) error {
return fmt.Errorf("encode config: %w", err)
}
}
if cfg.Schema != "" {
if err := enc.WriteToken(jsontext.String("schema")); err != nil {
return fmt.Errorf("encode config: %w", err)
}
if err := enc.WriteToken(jsontext.String(cfg.Schema)); err != nil {
return fmt.Errorf("encode config: %w", err)
}
}
if len(cfg.LocalEnv) > 0 {
if err := enc.WriteToken(jsontext.String("localEnv")); err != nil {
return fmt.Errorf("encode config: %w", err)
Expand Down Expand Up @@ -129,8 +141,8 @@ func writeSortedMap[V any](enc *jsontext.Encoder, m map[string]V) error {
// -platform replaces the config file's Platforms (an empty -platform defers to the config file
// rather than clearing it). -merge and -deny-warnings, when explicitly given, override Merge and
// DenyWarnings in either direction (e.g. "-merge=false" disables merging even if the config file
// sets "merge": true). A non-empty -format replaces the config file's Format. LocalEnv, Categories,
// and Rules are config-file only.
// sets "merge": true). A non-empty -format replaces the config file's Format, and a non-empty
// -schema replaces its Schema. LocalEnv, Categories, and Rules are config-file only.
func mergeConfig(opts Options, cfg Config) Config {
if len(opts.Platforms) > 0 {
cfg.Platforms = opts.Platforms
Expand All @@ -144,6 +156,9 @@ func mergeConfig(opts Options, cfg Config) Config {
if opts.Format != "" {
cfg.Format = opts.Format
}
if opts.Schema != "" {
cfg.Schema = opts.Schema
}
return cfg
}

Expand Down
4 changes: 4 additions & 0 deletions cmd/decolint/init.go
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,10 @@ func initConfigFile(output io.Writer) error {
// "format" selects the output format ("text", "json", "github", or
// "sarif"); the -format flag takes precedence, e.g.:
// "format": "github"
// "schema" selects the Dev Container schema variant ("main", "base", or
// "off"); "base" rejects VS Code/Codespaces properties, "off" disables
// schema validation; the -schema flag takes precedence, e.g.:
// "schema": "base"
// "localEnv" supplies the values ${localEnv:NAME} resolves to when merging,
// and the environment Compose-file interpolation reads; environment
// variables are never read, e.g.:
Expand Down
Loading
Loading