Skip to content

ADFA-6252 | Convert Flutter Templates from a plugin to a .cgt bundle - #111

Merged
hal-eisen-adfa merged 13 commits into
mainfrom
feat/ADFA-6252-flutter-cgt-template
Oct 5, 2026
Merged

hal-eisen-adfa merged 13 commits into
mainfrom
feat/ADFA-6252-flutter-cgt-template

Conversation

@hal-eisen-adfa

@hal-eisen-adfa hal-eisen-adfa commented Sep 25, 2026 •

Copy link
Copy Markdown
Contributor

Closes ADFA-6252.

The Flutter 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-Starter-Kit/ — the first addon under templates/. The five .peb skeletons move unchanged apart from two fixes below (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-starter-kit.cgt and 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 .cgt has been published yet. docs/addon-naming-standards.md now 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 in libs/.

Area Change
discover.py Second rule: a directory under templates/ holding templates.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 caller
scripts/build-plugins.sh New. assemblePlugin per plugin. With --ref/--local it first calls update-libs.sh; without either it uses the committed libs/
scripts/build-templates.sh New. build-cgt.sh per template, into dist/. Never touches libs/
scripts/update-libs.sh Now only refreshes libs/. It no longer builds anything and no longer takes --plugin
model.py Reads template.id / template.version / template.minAppVersion from addon.json where a plugin reads its manifest
catalog.py Builds v1 and v2; addonId and dl/<slug>.cgt in v2
check.py Template branch: templates.json parses, every path it names is a string, exists and carries a template.json, icons at the addon root
tarball.py Returns None — templates ship no source tarball
Workflows build-plugins.yml is renamed build-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 both

Each 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: pluginId becomes addonId (the field named one of four addon types), and sourceTarball stops 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.json keeps pluginId and keeps requiring sourceTarball. 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.json carries addonId, treats sourceTarball as optional, and is the only major that can describe a template.

site/catalog.schema.json is unchanged from its published v1 shape; site/catalog.v2.schema.json is the new contract. site/app.js reads 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 null into non-null Kotlin properties for missing keys, silently. Omitting sourceTarball from a v1 document is exactly that.

Why no source tarball

The .cgt is plain text throughout, so the published download already is the source, and sourceUrl still 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.json

The 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 -x exclude list fails open: a metadata file added next year ships until somebody remembers to extend it. Deriving the list from templates.json fails 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 .cgt carries cgt-build.properties at 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: ZipTemplateReader reads only templates.json and the paths it lists, ZipRecipeExecutor skips everything outside <TemplateName>/, and CgtTemplateReader matches only */template/template.json.

Fixes to the skeletons

  • Every pubspec.yaml was invalid YAML: Pebble's newline trimming merged name: and description: 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 a replace filter ("my_application2").

Both predate the conversion: the plugin shipped the same broken skeletons.

Documentation

  • 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 a Building section for the two scripts. The headless template-installer section is gone.
  • templates/README.md is the single document for templates: Part 1 is authoring guidance for community contributors, with Flutter-Starter-Kit as the reference example — what Random-XKCD is to a plugin — and Part 2 is the .cgt format reference, which had no written specification (ZipTemplateConstants.kt was 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):

  • All five templates appear on New Project beside the nine core ones, with correct names and thumbnails.
  • The wizard shows exactly the three declared parameters — no language picker, no minSdk.
  • A generated project has .peb suffixes stripped and no template/ directory.
  • The resulting pubspec.yaml parses with all eight expected keys, and the default app name gives name: "my_application2".

Also: 121 tests pass, addons check exits 0, the bundle is byte-identical across two builds, verify-provenance.sh passes on a clean tree and fails when the record is stripped, and all four workflows parse. Both build scripts run under macOS /bin/bash 3.2: a template name passed to build-plugins.sh builds nothing and exits 0, an unknown name exits 1, and random-xkcd.cgp and flutter-starter-kit.cgt both build.

Not verified: the renamed bundle on a device, the --ref/--local libs-refresh path of build-plugins.sh, and the workflows on GitHub (build-addons.yml is not on main, 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.

minAppVersion is 26.36, the first release with the Extensions Manager.

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.
@hal-eisen-adfa
hal-eisen-adfa requested a review from a team September 26, 2026 00:29
Comment thread .github/workflows/build-addons.yml Outdated
Comment thread .github/workflows/build-addons.yml Outdated
Comment thread scripts/build-cgt.sh
Comment thread tools/addons/src/addons/check.py Outdated
…/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
hal-eisen-adfa force-pushed the feat/ADFA-6252-flutter-cgt-template branch from 9a9c937 to 5cd9fad Compare October 2, 2026 22:46
…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.
@hal-eisen-adfa
hal-eisen-adfa requested a review from jatezzz October 2, 2026 23:09
@hal-eisen-adfa
hal-eisen-adfa merged commit f0e8510 into main Oct 5, 2026
@hal-eisen-adfa
hal-eisen-adfa deleted the feat/ADFA-6252-flutter-cgt-template branch October 5, 2026 18:03
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants