Skip to content

Commit 903930f

Browse files
committed
Add build scripts to compare against image tags
1 parent 7517ac5 commit 903930f

8 files changed

Lines changed: 455 additions & 0 deletions

File tree

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
name: "Compare Templates against Images"
2+
3+
on:
4+
pull_request:
5+
push:
6+
branches:
7+
- main
8+
schedule:
9+
- cron: '0 2 * * 1-5'
10+
workflow_dispatch:
11+
12+
permissions:
13+
contents: read
14+
15+
jobs:
16+
check-image-tags:
17+
name: Check Image Tags (Last Release)
18+
runs-on: ubuntu-latest
19+
steps:
20+
- uses: actions/checkout@v5
21+
22+
- name: List template images
23+
run: npx tsx build/list-template-images.ts
24+
25+
- uses: actions/checkout@v5
26+
with:
27+
repository: devcontainers/images
28+
fetch-depth: 0
29+
path: images
30+
31+
- name: Check out last release tag
32+
run: |
33+
cd images
34+
tag=$(git describe --tags --abbrev=0)
35+
echo "Checking out tag: $tag"
36+
git checkout "$tag"
37+
38+
- name: Check image tags (last release)
39+
run: npx tsx build/check-image-tags.ts images
40+
41+
check-image-tags-latest:
42+
name: Check Image Tags (Latest)
43+
runs-on: ubuntu-latest
44+
steps:
45+
- uses: actions/checkout@v5
46+
47+
- uses: actions/checkout@v5
48+
with:
49+
repository: devcontainers/images
50+
path: images
51+
52+
- name: Check image tags (latest)
53+
run: |
54+
if ! npx tsx build/check-image-tags.ts images; then
55+
echo "::warning::Image tag check against latest images failed - upcoming release may break templates"
56+
fi

.github/workflows/test-pr.yaml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,9 @@ name: "PR - Test Updated Templates"
22
on:
33
pull_request:
44

5+
permissions:
6+
contents: read
7+
58
jobs:
69
detect-changes:
710
runs-on: ubuntu-latest

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,3 @@
11
*.DS_Store
22
Thumbs.db
3+
node_modules

build/check-image-tags.ts

Lines changed: 260 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,260 @@
1+
/**
2+
* Compares the concrete image tags that each template would produce (for every
3+
* proposed option value) against the set of tags the images repo would publish
4+
* (based on each manifest's version, variants, tags and variantTags).
5+
*
6+
* Usage: npx tsx build/check-image-tags.ts <path-to-images-repo>
7+
* Example: npx tsx build/check-image-tags.ts ../images
8+
*/
9+
10+
import * as fs from 'fs';
11+
import * as path from 'path';
12+
13+
const RED = '\x1b[0;31m';
14+
const GREEN = '\x1b[0;32m';
15+
const YELLOW = '\x1b[0;33m';
16+
const NC = '\x1b[0m';
17+
18+
const MCR_PREFIX = 'mcr.microsoft.com/devcontainers/';
19+
const IMAGE_REF_PATTERN = /mcr\.microsoft\.com\/devcontainers\/([^"]+)/g;
20+
const TEMPLATE_OPTION_PATTERN = /\$\{templateOption:([^}]+)\}/;
21+
22+
interface ImageManifest {
23+
version: string;
24+
variants?: string[];
25+
build: {
26+
tags?: string[];
27+
variantTags?: Record<string, string[]>;
28+
};
29+
}
30+
31+
interface TemplateJson {
32+
options?: Record<string, {
33+
default?: string;
34+
proposals?: string[];
35+
}>;
36+
}
37+
38+
interface TemplateTag {
39+
templateName: string;
40+
relFile: string;
41+
tag: string;
42+
}
43+
44+
// --- Step 1: Compute all tags that images would publish ---
45+
46+
function computeImageTags(imagesRepo: string): Set<string> {
47+
const tags = new Set<string>();
48+
const srcDir = path.join(imagesRepo, 'src');
49+
50+
for (const imageDir of fs.readdirSync(srcDir)) {
51+
const manifestPath = path.join(srcDir, imageDir, 'manifest.json');
52+
if (!fs.existsSync(manifestPath)) continue;
53+
54+
const manifest: ImageManifest = JSON.parse(fs.readFileSync(manifestPath, 'utf-8'));
55+
const major = manifest.version.split('.')[0];
56+
const variants = manifest.variants ?? [];
57+
const buildTags = manifest.build.tags ?? [];
58+
const variantTags = manifest.build.variantTags ?? {};
59+
const versionedTagsOnly = (manifest as any).build?.versionedTagsOnly;
60+
61+
// The build system generates tags for each version granularity
62+
// (e.g. 2.1.6 -> ['2.1.6', '2.1', '2', '']). We only need major
63+
// and floating (empty) for comparison purposes.
64+
const versions = [major];
65+
if (!versionedTagsOnly) {
66+
versions.push('');
67+
}
68+
69+
// Apply a version+variant to a tag pattern, mimicking the build
70+
// system's replacement logic.
71+
function expandTag(pattern: string, version: string, variant?: string): string | null {
72+
let tag = pattern
73+
.replace(/\$\{VERSION\}/g, version)
74+
.replace(':-', ':')
75+
.replace(/\$\{?VARIANT\}?/g, variant ?? 'NOVARIANT')
76+
.replace('-NOVARIANT', '');
77+
if (tag.endsWith(':')) return null;
78+
return tag;
79+
}
80+
81+
for (const version of versions) {
82+
if (variants.length > 0) {
83+
// Expand tags × variants
84+
for (const variant of variants) {
85+
for (const tagPattern of buildTags) {
86+
const tag = expandTag(tagPattern, version, variant);
87+
if (tag) tags.add(tag);
88+
}
89+
}
90+
} else {
91+
// No variants — expand tags directly (e.g. anaconda, universal)
92+
for (const tagPattern of buildTags) {
93+
const tag = expandTag(tagPattern, version);
94+
if (tag) tags.add(tag);
95+
}
96+
}
97+
98+
// Expand variantTags (these don't use ${VARIANT})
99+
for (const extraTags of Object.values(variantTags)) {
100+
for (const tagPattern of extraTags) {
101+
const tag = expandTag(tagPattern, version);
102+
if (tag) tags.add(tag);
103+
}
104+
}
105+
}
106+
}
107+
108+
return tags;
109+
}
110+
111+
// --- Step 2: Compute all tags that templates would produce ---
112+
113+
function findFiles(dir: string, names: string[]): string[] {
114+
const results: string[] = [];
115+
function walk(d: string) {
116+
for (const entry of fs.readdirSync(d, { withFileTypes: true })) {
117+
const full = path.join(d, entry.name);
118+
if (entry.isDirectory()) {
119+
walk(full);
120+
} else if (names.includes(entry.name) || names.some(n => n.startsWith('*.') && entry.name.endsWith(n.slice(1)))) {
121+
results.push(full);
122+
}
123+
}
124+
}
125+
walk(dir);
126+
return results;
127+
}
128+
129+
function computeTemplateTags(templatesDir: string): TemplateTag[] {
130+
const results: TemplateTag[] = [];
131+
132+
for (const templateName of fs.readdirSync(templatesDir)) {
133+
const templateDir = path.join(templatesDir, templateName);
134+
if (!fs.statSync(templateDir).isDirectory()) continue;
135+
136+
const templateJsonPath = path.join(templateDir, 'devcontainer-template.json');
137+
if (!fs.existsSync(templateJsonPath)) continue;
138+
139+
const templateJson: TemplateJson = JSON.parse(fs.readFileSync(templateJsonPath, 'utf-8'));
140+
const files = findFiles(templateDir, ['devcontainer.json', 'Dockerfile', '*.yml', '*.yaml']);
141+
142+
for (const file of files) {
143+
const content = fs.readFileSync(file, 'utf-8');
144+
const relFile = path.relative(templatesDir, file);
145+
const isDockerfile = path.basename(file) === 'Dockerfile';
146+
147+
for (const line of content.split('\n')) {
148+
// Skip comment lines
149+
if (isDockerfile && /^\s*#/.test(line)) continue;
150+
if (/^\s*\/\//.test(line)) continue;
151+
152+
for (const match of line.matchAll(IMAGE_REF_PATTERN)) {
153+
const pattern = match[1]; // e.g. "typescript-node:1-${templateOption:imageVariant}"
154+
const optionMatch = pattern.match(TEMPLATE_OPTION_PATTERN);
155+
156+
if (optionMatch) {
157+
const optionName = optionMatch[1];
158+
const option = templateJson.options?.[optionName];
159+
160+
if (option) {
161+
const values = [...new Set([...(option.proposals ?? []), ...(option.default != null ? [option.default] : [])])];
162+
for (const value of values) {
163+
const tag = pattern.replace(`\${templateOption:${optionName}}`, value);
164+
results.push({ templateName, relFile, tag });
165+
}
166+
} else {
167+
// Option not found in template.json — output raw
168+
results.push({ templateName, relFile, tag: pattern });
169+
}
170+
} else {
171+
// Static tag
172+
results.push({ templateName, relFile, tag: pattern });
173+
}
174+
}
175+
}
176+
}
177+
}
178+
179+
// Deduplicate by templateName + tag, keeping first relFile
180+
const seen = new Map<string, TemplateTag>();
181+
for (const entry of results) {
182+
const key = `${entry.templateName}\t${entry.tag}`;
183+
if (!seen.has(key)) {
184+
seen.set(key, entry);
185+
}
186+
}
187+
188+
return [...seen.values()].sort((a, b) =>
189+
a.templateName.localeCompare(b.templateName) || a.tag.localeCompare(b.tag)
190+
);
191+
}
192+
193+
// --- Step 3: Compare ---
194+
195+
function main() {
196+
const imagesRepo = process.argv[2];
197+
if (!imagesRepo) {
198+
console.error('Usage: npx tsx build/check-image-tags.ts <path-to-images-repo>');
199+
process.exit(1);
200+
}
201+
202+
const templatesDir = path.resolve(__dirname, '..', 'src');
203+
204+
const imageTags = computeImageTags(imagesRepo);
205+
console.log('=== Published image tags (from manifests) ===');
206+
console.log(`${imageTags.size} unique tags\n`);
207+
208+
const templateTags = computeTemplateTags(templatesDir);
209+
console.log('=== Template tags ===');
210+
console.log(`${templateTags.length} unique template/tag combinations\n`);
211+
212+
console.log('=== Comparison ===\n');
213+
214+
let errors = 0;
215+
let prevTemplate = '';
216+
217+
for (const { templateName, relFile, tag } of templateTags) {
218+
if (templateName !== prevTemplate) {
219+
if (prevTemplate) console.log('');
220+
console.log(`${templateName.padEnd(30)} (${relFile})`);
221+
prevTemplate = templateName;
222+
}
223+
224+
if (imageTags.has(tag)) {
225+
console.log(` ${GREEN}OK${NC} ${tag}`);
226+
} else {
227+
console.log(` ${RED}MISSING${NC} ${tag}`);
228+
errors++;
229+
}
230+
}
231+
232+
// Collect the set of all tags referenced by templates
233+
const templateTagSet = new Set(templateTags.map(t => t.tag));
234+
235+
// Find image tags not referenced by any template
236+
const unreferencedImageTags = [...imageTags].filter(t => !templateTagSet.has(t)).sort();
237+
238+
if (unreferencedImageTags.length > 0) {
239+
console.log('\n=== Image tags not in any template ===\n');
240+
for (const tag of unreferencedImageTags) {
241+
console.log(` ${YELLOW}UNUSED${NC} ${tag}`);
242+
}
243+
}
244+
245+
console.log('\n');
246+
if (errors > 0) {
247+
console.log(`${RED}Found ${errors} template tag(s) not in image manifests.${NC}`);
248+
if (unreferencedImageTags.length > 0) {
249+
console.log(`${YELLOW}Found ${unreferencedImageTags.length} image tag(s) not referenced by any template.${NC}`);
250+
}
251+
process.exit(1);
252+
} else {
253+
console.log(`${GREEN}All template tags match published image tags.${NC}`);
254+
if (unreferencedImageTags.length > 0) {
255+
console.log(`${YELLOW}Found ${unreferencedImageTags.length} image tag(s) not referenced by any template.${NC}`);
256+
}
257+
}
258+
}
259+
260+
main();

0 commit comments

Comments
 (0)