Repository navigation
ADFA-6252 | Convert Flutter Templates from a plugin to a .cgt bundle - #111
Merged
Merged
Conversation
The templates shipped as a headless plugin only because no .cgt path existed when they were added: on activate() it built a .cgt on the device from bundled Pebble skeletons and registered it. The Extensions manager reads a .cgt directly now, so the plugin wrapper is dead weight. plugins/Flutter-Templates/ becomes templates/Flutter-Templates/ — the first addon under templates/. The five .peb skeletons move unchanged (git mv, so their history follows); the Gradle project, the manifest, the Kotlin class and the res/ tree are deleted. A GitHub Action zips the directory into flutter-templates.cgt and publishes it to R2 like every other addon. The bundle is never committed. Generalizing the tooling from plugins to addons: - discover.py gains a second rule. A directory under templates/ holding templates.json is an addon; the plugin predicate can never match one, because a template has no Gradle build. - model.py reads template.id / template.version / template.minAppVersion from addon.json where a plugin reads its manifest. addon.schema.json gains the template block. - catalog.py renames pluginId to addonId and bumps schemaVersion to 2. The field named one of four addon types; site/app.js never read it and no CodeOnTheGo code references catalog.json, so nothing observes the change today, which is what makes now the cheap moment. - Templates ship no source tarball: the .cgt is plain text throughout, so the download already is the source, and sourceUrl still names the directory. tarball.build() returns None, sourceTarball leaves the schema's required list, and site/app.js drops the Source link. - build-plugins.yml becomes build-addons.yml. It already called its subjects addons in its own comments and input description; once it also produces .cgt files the old name was simply wrong. Two bugs found while verifying rather than by review: - site/app.js read addon.sourceTarball.url unguarded, inside the loop that builds every card. One entry without the field would throw a TypeError and leave the whole gallery on "Loading…", not merely drop one link. - The dirty check in build-cgt.sh passed the addon path as a pathspec after git -C had already resolved against it, so it looked for <dir>/<dir>, matched nothing, and no build was ever reported dirty. Provenance: every published .cgt carries cgt-build.properties at its archive root, recording the commit and that commit's committer date. The gallery serves one URL per addon and overwrites it, so without this "version 1.0.0" names every build that number ever had. A root entry is inert to all three readers — ZipTemplateReader reads only templates.json and the paths it lists, ZipRecipeExecutor skips everything outside <TemplateName>/, and CgtTemplateReader matches only */template/template.json. scripts/build-cgt.sh takes its file list from templates.json rather than excluding the four known metadata files. That fails closed (a metadata file added later is never listed, so it cannot leak into a user-facing download) and fails loudly (find exits non-zero when templates.json names a path that does not exist — an entry the IDE otherwise skips in silence). Entries are stored with mtimes pinned to 1980 and the list C-sorted, so one commit gives one archive; verified byte-identical across two builds. check-toolchain.yml gains the dev-assets lint steps (junk files, XML, strict JSON, JSON5, Pebble braces) plus a build of every bundle. It is the only workflow here that runs on a pull request, so it is the only place a broken bundle can be caught before merge. Docs: the plugin-specific guidance moves out of CLAUDE.md into plugins/README.md, CLAUDE.md gains a table pointing at each of the four areas, and the headless template-installer section is gone. docs/plugin-naming-standards.md becomes docs/addon-naming-standards.md. templates/README.md is authoring guidance for community contributors, with Flutter-Templates as the reference example, and templates/cgt-templates.md documents the format — which had no written specification; ZipTemplateConstants.kt was the only definition. Users with the old plugin installed must disable it before adding the bundle, or each template appears twice from two unlinked sources. Noted in the addon README and on the gallery page.
Device verification caught this; no build or lint step could. Generating a
project produced:
name: bloccounterdemodescription: Flutter BLoC project created by ...
which a YAML parser rejects outright with "mapping values are not allowed
here", so `flutter pub get` could never have run on a generated project.
The cause is the newline-trimming gotcha already documented in
templates/cgt-templates.md: a bare ${{TAG}} at end of line loses its trailing
newline to Pebble, so the line merges with the next. All five pubspec.yaml.peb
opened with
name: ${{APP_NAME | lower}}
description: ...
and lost the break between them. The documented convention is to put a
non-newline character after }}, so the value is now quoted — which is ordinary
YAML anyway:
name: "${{APP_NAME | lower}}"
The same trimming hit `# ${{APP_NAME}}` at the top of each README.md.peb,
eating the blank line between the heading and the first paragraph. Markdown
still renders it, so this one is cosmetic; a compensating blank line restores
the intended output.
This predates the conversion — the plugin shipped the same skeletons and
produced the same broken YAML. It survived because the only check anyone ran
was that the plugin compiled, and the brace-balance lint counts delimiters
without rendering anything.
Also updated the description and README line that both generated projects
carry: they credited "the Flutter Template plugin", which no longer exists.
Verified on emulator-5554: all five templates appear on New Project with their
thumbnails, the wizard shows exactly the three declared parameters (no language
picker, no minSdk), a generated project has the .peb suffixes stripped and no
template/ directory, and the resulting pubspec.yaml parses with all eight
expected keys.
It was scaffolding for this change and has no reader once the work has landed. The two documents that outlive it stay: cgt-templates.md for the format, README.md for writing a template. cgt-templates.md linked to it twice. Both links are replaced rather than deleted — the decision table they introduced is the part worth keeping, so it now stands on its own under the ticket number.
Two documents covered one subject and overlapped on the parts people actually need — the Pebble delimiters, the identifiers, path substitution and the gotchas were in both, which is how they drift. One file now: Part 1 is how to write a template, Part 2 is the format. Dropped on the way in, rather than carried across: - The five-gap table (G01-G05) listing what this repository had to add. All of it is implemented; it described a plan, not the system. - The record of what the Flutter conversion involved, for the same reason the migration plan went. - The dev-assets publication path, the local build-core-cgt.sh warning, and the CodeOnTheGoPlugin jar-fetching detail. That is how core.cgt reaches the app assets over SSH; addons here go to R2 and nothing about it guided a reader. - The absolute /Users/eisen paths in the sources table, replaced by repository- relative pointers to the CodeOnTheGo files that define the format. Added while merging, because the reference described dev-assets and not this repository: how scripts/build-cgt.sh builds the archive, that an unrecognised root entry is ignored (which is what makes cgt-build.properties safe), and how publishing is wired from discovery through to the catalog entry. core.cgt stays in as the larger worked example. It exercises the language chooser, user parameters and path flags, none of which the Flutter bundle shows.
Section 12 said to copy the .cgt into Downloads, open the manager and go to New Project. That is not enough, and the missing steps are the ones an author cannot guess. Walked on a device, the flow is: Preferences -> Extensions Manager (the screen is titled "Plugins & Templates") -> Templates tab -> the + button -> the system file picker, which opens in CodeOnTheGoProjects rather than Downloads -> select the .cgt -> an "Install Template Collection" dialog listing every template in the bundle -> Install. Only then is the file copied into the IDE's templates directory and only then does New Project show anything. The earlier wording came from a source comment describing the Templates tab as passively scanning TEMPLATES_DIR and Downloads. That scan is real, but it only populates the list: a bundle sitting in Downloads appears there marked "Not installed - Imported". Listing and installing are separate, and the format reference said otherwise. The table now distinguishes Bundled from Imported and says which state reaches New Project. Two things worth an author's attention, both found by doing it: - The install dialog names each template in templates.json order, from the name field of each template/template.json. It is the earliest place a missing entry, a wrong order or a typo shows up, before anything is generated. - Section 12 now says to run generated files that have a strict syntax through a real parser. Reading them is what let the broken pubspec.yaml survive. Corrected the same claim in the Flutter bundle's README and on its gallery page, both of which told a user that dropping the file in Downloads was the whole installation. Also fixed the UI name in docs/addon-naming-standards.md: there is no "Templates manager" or "Plugin Manager" screen, there is one Extensions Manager with a Plugins tab and a Templates tab.
All verified before fixing, and the fixes verified after. update-libs.sh ran Gradle in the template directory. Its loop takes whatever `addons discover` returns, which now includes templates/Flutter-Templates, and assemblePlugin has no build there -- so the documented full-build command, and the first step of the new build-addons.yml, failed before reaching the template step. discover gains --plugins-only, so the rule for what a template is stays in one place rather than becoming a shell filter. build-cgt.sh used mapfile, a bash 4 builtin. macOS ships 3.2, and update-libs.sh already carries a comment saying exactly this. My own run passed only because Homebrew bash 5.3 is ahead of /bin/bash on this machine. Now spelled out as a read loop, and tested under /bin/bash 3.2. The provenance timestamp was seven hours wrong. `export TZ=UTC` sat below the write_provenance call, so --date=format-local rendered in the caller's zone while the format string hard-coded a Z. Commit 9dd002d is 15:48:38-07:00 and was recorded as 15:48:38Z rather than 22:48:38Z. Two machines in different zones also produced different records for one commit. TZ is now set on the git command itself, which cannot drift from its position in the file. The timestamp also came from HEAD rather than from the recorded revision. On a pull request GitHub checks out a merge commit, so the record would have paired GITHUB_SHA with a different commit's date. The file list was directory-granular, not file-granular: `find -type f` swept up anything sitting under a listed template directory. A .DS_Store, or a .dart_tool/ and pubspec.lock after someone ran a generated project, shipped to users and broke the byte-for-byte claim. check-toolchain.yml only rejects junk that was committed. The list now comes from git ls-files, as tarball.py already does. The script also mutated the caller's tree, rewriting every packaged file's mtime to 1980 and leaving cgt-build.properties behind with no cleanup. It now stages into a temp directory under a trap, so a build has no side effects. Confirmed: no leftover file, mtimes untouched, and the archive still byte-identical across two runs. `${{APP_NAME | lower}}` produced an invalid Dart package name for any multi-word app name -- including the default. "My Application2" gave `name: "my application2"`, which is valid YAML and which flutter pub get rejects. The earlier fix only made it parse as YAML; it did not make it a package name. Now folded with a replace filter, and verified on a device: the default app name generates `name: "my_application2"`. check.py assumed the parsed templates.json was an object, so a top-level array raised AttributeError and the required PR gate died with a bare traceback instead of the named problem it exists to report. Two documentation corrections found while testing on the device: re-installing a bundle does not silently replace the old one, it offers Overwrite / Rename & Install / Cancel; and the placeholder section now says a bare `| lower` is not enough for a value that has to be an identifier. Not in this commit: the catalog rename ships a breaking change over the v1/ path, against section 10.2 of the addon-distribution design. The follow-up commit publishes v2/ alongside v1/ as that section prescribes.
The template work needed two changes to the catalog that section 10.5 of the addon-distribution design forbids within a major version: pluginId became addonId, because the field named one of four addon types, and sourceTarball stopped being required, because a template ships none. Both shipped over the unchanged v1/ path. Section 10.2 prescribes the alternative: a breaking change publishes the new major alongside the old one, which keeps being written, because the main consumer is a fielded Android app that cannot be force-updated. 10.5 also says every field is always present on every entry, and says why -- the app deserializes with a bare Gson instance, which writes null into non-null Kotlin properties for missing keys and does so in silence. Dropping sourceTarball from a v1 document is exactly that hazard. So both majors are now generated and published: - v1/catalog.json keeps pluginId and keeps requiring sourceTarball. It omits template entries, which is the one removal 10.5 permits within a major, so a v1 consumer sees the plugins it already knew and nothing it cannot install. - v2/catalog.json carries addonId, treats sourceTarball as optional, and is the only major that can describe a template. site/catalog.schema.json returns to its published v1 shape and site/catalog.v2.schema.json is the new contract. Both are uploaded under their own prefix. publish() now takes a list of catalogs rather than one, and still writes all of them after every object they reference -- ordering is the only atomicity R2 offers (10.6). site/app.js reads v2. The page ships with each publish, so it is never a stale consumer, and v2 is the only major that lists templates. This reverses the choice made earlier in the ticket. That choice was mine to inform and I informed it badly: I reported that no consumer reads the field, having grepped CodeOnTheGo for catalog.json and pluginId and found nothing. I had not found the design document, which names the consumer and prescribes the migration. One bug fixed on the way in: entry() took the schema version as `version`, which the addon's own version string shadowed three lines later. Renamed schema_version.
jatezzz
reviewed
Sep 28, 2026
jatezzz
reviewed
Sep 28, 2026
jatezzz
reviewed
Sep 28, 2026
jatezzz
reviewed
Sep 28, 2026
…/README.md ADFA-6269 (#112) edited two CLAUDE.md paragraphs that this branch had moved to plugins/README.md: Publish addons now builds against CodeOnTheGo's plugin-api-latest release rather than the committed libs/, and libs_revision comes from that release's target commit rather than a commit subject. CLAUDE.md keeps this branch's version. The new wording goes to where the paragraphs now live, so it is not lost in the move.
Review feedback: a single-template run of Build addon artifacts always failed. The first step passed any addon name to update-libs.sh --plugin, which lists only plugins, so it exited 1 before the template step ran. The three steps each resolved the name their own way, and the artifact check matched case-sensitively where the template step did not. Each kind now has one script, and every workflow calls it: - scripts/build-plugins.sh runs assemblePlugin per plugin. With --ref or --local it first calls update-libs.sh; without either it uses the committed libs/, which is what Publish addons needs after it fetches the plugin-api-latest jars. - scripts/build-templates.sh runs build-cgt.sh per template into dist/. A template needs no Code on the Go jars, so it never touches libs/. - update-libs.sh only refreshes libs/. It writes the Code on the Go sha to .cache/libs-revision, because an export cannot reach the caller, and build-plugins.sh exports PLUGIN_LIBS_REVISION from it. Both scripts list addons with `addons discover --kind plugin|template [NAME...]`, which replaces --plugins-only. Names match by directory name or path in any case. A name of the other kind is dropped and an unknown name fails, so both scripts take the same arguments. build-plugins.sh resolves names before it refreshes libs/, so a template-only run does not clone Code on the Go. The artifact-check step in build-addons.yml is gone: each script fails on the addon that did not build. The publish stage step, check-toolchain's bundle step and update-libs.yml use the scripts instead of inline loops. CLAUDE.md gains a Building section; plugins/README.md, templates/README.md and README.md describe the new entry points.
check.py only tested the templates.json 'path' value for truthiness, so
{"path": 5} reached the string tests and raised TypeError. The required
PR gate then died with a bare traceback rather than naming the bad entry.
…tensions Manager 26.38 was a guess left open in the PR. The release notes put the Extensions Manager, which installs a .cgt, in 26.36. The authoring guide now says so, so a new bundle does not copy the wrong floor.
hal-eisen-adfa
force-pushed
the
feat/ADFA-6252-flutter-cgt-template
branch
from
October 2, 2026 22:46
9a9c937 to
5cd9fad
Compare
…ay name
A name pasted into the workflow-dispatch form often carries a space, and the
gallery card shows the display name ("Flutter Templates"), so a user can
type either. Both now fold to the same key as the directory name. The key
stays unique because every form derives from the directory name.
An addon's official name does not say "plugin" or "template": the area it lives in already says that. Flutter-Templates becomes Flutter-Starter-Kit, so the slug, the .cgt, the page and the template id follow. The id can still change because no .cgt has been published yet. References to the old plugin keep its name, Flutter Templates, because that is what a user sees in the Plugin Manager. docs/addon-naming-standards.md adds "template" to the type-words to drop and explains the singular.
jatezzz
approved these changes
Oct 5, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes ADFA-6252.
The Flutter templates shipped as a headless plugin only because no
.cgtpath existed when they were added: onactivate()it built a.cgton the device from bundled Pebble skeletons and registered it. The Extensions Manager reads a.cgtdirectly now, so the plugin wrapper is dead weight.plugins/Flutter-Templates/becomestemplates/Flutter-Starter-Kit/— the first addon undertemplates/. The five.pebskeletons move unchanged apart from two fixes below (git mv, so their history follows); the Gradle project, the manifest, the Kotlin class and theres/tree are deleted. A GitHub Action zips the directory intoflutter-starter-kit.cgtand publishes it to R2 like every other addon. The bundle is never committed.Why the new name
An addon's official name does not say "plugin" or "template": the area it lives in already says that. So the bundle is Flutter Starter Kit, and the slug, the
.cgt, the page and the template id (org.appdevforall.flutterstarterkit) follow. The id can still change because no.cgthas been published yet.docs/addon-naming-standards.mdnow lists "template" among the type-words to drop. Text about the old plugin keeps its name, Flutter Templates, because that is what a user sees in the Plugin Manager.Generalizing the tooling from plugins to addons
A template is not a plugin: no Gradle build, no
AndroidManifest.xml, no APK, and no need for the Code on the Go jars inlibs/.discover.pytemplates/holdingtemplates.json.discover --kind plugin|template [NAME...]lists one kind. Names match by directory name, slug, path or display name, in any case, with stray whitespace — one matcher for every callerscripts/build-plugins.shassemblePluginper plugin. With--ref/--localit first callsupdate-libs.sh; without either it uses the committedlibs/scripts/build-templates.shbuild-cgt.shper template, intodist/. Never toucheslibs/scripts/update-libs.shlibs/. It no longer builds anything and no longer takes--pluginmodel.pytemplate.id/template.version/template.minAppVersionfromaddon.jsonwhere a plugin reads its manifestcatalog.pyv1andv2;addonIdanddl/<slug>.cgtin v2check.pytemplates.jsonparses, every path it names is a string, exists and carries atemplate.json, icons at the addon roottarball.pyNone— templates ship no source tarballbuild-plugins.ymlis renamedbuild-addons.yml. All four workflows call the two build scripts instead of inline loops. A build script ignores a name of the other kind, so one name goes to bothEach script builds only its own kind, so a single-template run no longer reaches Gradle, and a template-only run does not clone Code on the Go.
Why a new catalog major, published alongside v1
Two changes the template work needs are ones §10.5 of the addon-distribution design forbids within a major version:
pluginIdbecomesaddonId(the field named one of four addon types), andsourceTarballstops being required (a template ships none).§10.2 prescribes the answer: a breaking change publishes the new major alongside the old one, which keeps being written, because the main consumer is a fielded Android app that cannot be force-updated. So both are generated:
v1/catalog.jsonkeepspluginIdand keeps requiringsourceTarball. It omits template entries — the one removal §10.5 permits within a major — so a v1 consumer sees the plugins it already knew and nothing it cannot install.v2/catalog.jsoncarriesaddonId, treatssourceTarballas optional, and is the only major that can describe a template.site/catalog.schema.jsonis unchanged from its published v1 shape;site/catalog.v2.schema.jsonis the new contract.site/app.jsreads v2 — the page ships with each publish, so it is never a stale consumer.§10.5 also explains the hazard directly: the app deserializes with a bare Gson instance, which writes
nullinto non-null Kotlin properties for missing keys, silently. OmittingsourceTarballfrom a v1 document is exactly that.Why no source tarball
The
.cgtis plain text throughout, so the published download already is the source, andsourceUrlstill names the directory. A plugin tarball exists so somebody can unpack it and build it — it copies the shared jars in, copies the Gradle wrapper in, rewrites../libs/references. None of that applies.Why the file list comes from
templates.jsonThe addon directory holds gallery metadata (
addon.json, the HTML page, two icons,README.md) beside the template tree, and only the second belongs in the download. An-xexclude list fails open: a metadata file added next year ships until somebody remembers to extend it. Deriving the list fromtemplates.jsonfails closed (a file nobody listed can never be packaged) and fails loudly (a listed path that does not exist fails the build — an entry the IDE otherwise skips in silence).Provenance
Every published
.cgtcarriescgt-build.propertiesat its archive root, recording the commit and that commit's committer date (never the wall clock, which would end determinism). The gallery serves one URL per addon and overwrites it, so without this "version 1.0.0" names every build that number ever had.A root entry is inert to all three readers:
ZipTemplateReaderreads onlytemplates.jsonand the paths it lists,ZipRecipeExecutorskips everything outside<TemplateName>/, andCgtTemplateReadermatches only*/template/template.json.Fixes to the skeletons
pubspec.yamlwas invalid YAML: Pebble's newline trimming mergedname:anddescription:into one line. The value is now quoted.${{APP_NAME | lower}}gave an invalid Dart package name for any multi-word app name, including the default. It is now folded with areplacefilter ("my_application2").Both predate the conversion: the plugin shipped the same broken skeletons.
Documentation
CLAUDE.mdintoplugins/README.md.CLAUDE.mdgains a table pointing at each of the four areas and a Building section for the two scripts. The headless template-installer section is gone.templates/README.mdis the single document for templates: Part 1 is authoring guidance for community contributors, withFlutter-Starter-Kitas the reference example — whatRandom-XKCDis to a plugin — and Part 2 is the.cgtformat reference, which had no written specification (ZipTemplateConstants.ktwas the only definition).docs/plugin-naming-standards.md→docs/addon-naming-standards.md.Verification
Build success proves nothing here, so this was exercised end to end on
emulator-5554(under the earlier name, before the rename):.pebsuffixes stripped and notemplate/directory.pubspec.yamlparses with all eight expected keys, and the default app name givesname: "my_application2".Also: 121 tests pass,
addons checkexits 0, the bundle is byte-identical across two builds,verify-provenance.shpasses on a clean tree and fails when the record is stripped, and all four workflows parse. Both build scripts run under macOS/bin/bash3.2: a template name passed tobuild-plugins.shbuilds nothing and exits 0, an unknown name exits 1, andrandom-xkcd.cgpandflutter-starter-kit.cgtboth build.Not verified: the renamed bundle on a device, the
--ref/--locallibs-refresh path ofbuild-plugins.sh, and the workflows on GitHub (build-addons.ymlis not onmain, so it cannot be dispatched from this branch).For the release note
A user with the old Flutter Templates plugin still installed who adds this bundle sees each template twice, from two unlinked sources. The bundle cannot detect or clean up the plugin's copies. Disabling the plugin fixes it by its own existing path —
deactivate()already unregisters its templates and deletes its staged files. This is called out in the addon README and on the gallery page.minAppVersionis26.36, the first release with the Extensions Manager.