breakaway 1.4.0-main.22 → 1.4.0-main.24

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "breakaway",
3
- "version": "1.4.0-main.22",
3
+ "version": "1.4.0-main.24",
4
4
  "description": "The task board for you and your coding agents: a Cloudflare Worker, its web app, Taskwarrior sync, and the CLI (npx breakaway).",
5
5
  "license": "FSL-1.1-Apache-2.0",
6
6
  "type": "module",
@@ -615,6 +615,167 @@ export function runScripts(text) {
615
615
  );
616
616
  }
617
617
 
618
+ // ---- repos init --pipeline -----------------------------------------------------------------
619
+
620
+ /** Where the minimal CI goes in a repository that has no workflow yet, and the line it starts with. */
621
+ export const CI_PATH = '.github/workflows/ci.yml';
622
+ export const CI_HEADER =
623
+ 'Added by npx breakaway repos init --pipeline, because this repository had no workflow: the checks Deploy and Release wait for.';
624
+ /**
625
+ * The starter deploy paths: every file but the docs and the board's own, so the first deploys don't miss a change.
626
+ * The owner narrows it to the Worker's code.
627
+ */
628
+ export const STARTER_PATHS = '^(?!docs/|tools/tasks/|\\.agents/|\\.claude/|\\.github/|[^/]+\\.md$)';
629
+ /** The workflow names the rendered files take, never a check of their own. */
630
+ const RENDERED_NAMES = new Set(['Deploy', 'Promote', 'Roll back', 'Release']);
631
+ /** npm's placeholder test script, which fails: a package.json with it has no tests yet. */
632
+ const NO_TEST = /no test specified/u;
633
+
634
+ /** How the repository installs and runs a script, from its lockfile. */
635
+ function managerOf(readTarget) {
636
+ if (readTarget('pnpm-lock.yaml') !== null)
637
+ return { install: 'corepack pnpm install --frozen-lockfile', run: 'corepack pnpm run' };
638
+ if (readTarget('yarn.lock') !== null) return { install: 'yarn install --frozen-lockfile', run: 'yarn run' };
639
+ if (readTarget('package-lock.json') !== null) return { install: 'npm ci', run: 'npm run' };
640
+ return { install: 'npm install', run: 'npm run' };
641
+ }
642
+
643
+ /** A workflow's `name:` when it runs on a push, which is what a check has to do to gate a deploy; else null. */
644
+ function pushCheckName(text) {
645
+ let doc;
646
+ try {
647
+ doc = parseYaml(text);
648
+ } catch {
649
+ return null;
650
+ }
651
+ const on = doc?.on;
652
+ const push =
653
+ typeof on === 'string' ? on === 'push' : Array.isArray(on) ? on.includes('push') : isObject(on) && 'push' in on;
654
+ return push && typeof doc.name === 'string' && doc.name.trim() ? doc.name.trim() : null;
655
+ }
656
+
657
+ /**
658
+ * What `repos init <slug> --pipeline` and `--package` add (BRK-91, docs/specs/IDEA-27-move-ci-cd-to-the-deploy-flow.md,
659
+ * section 4): a starter `.github/breakaway-pipeline.json`, the files it renders, and, when the repository has no
660
+ * workflow at all, a minimal CI for them to wait for. All of it is new files: a repository that already has the
661
+ * config or a file it would render gets nothing from here, and is told to use `pipeline init`.
662
+ *
663
+ * `workers` is the staging and production Worker for the deploy flow (null for none); `withPackage` asks for the
664
+ * release flow of the package that package.json names, which it gets only when package.json is there and isn't
665
+ * private. `readTarget(path)` reads the repository (null when the file isn't there), `workflows` are its workflow
666
+ * files, and `readTemplate(name)` gives template/pipeline/<name>.
667
+ * @param {{
668
+ * branch?: string,
669
+ * workers?: { staging: string, production: string } | null,
670
+ * withPackage?: boolean,
671
+ * readTarget: (path: string) => string | null,
672
+ * workflows?: Array<{ path: string, text: string }>,
673
+ * readTemplate: (name: string) => string,
674
+ * }} input
675
+ * @returns {{ files: Array<{ path: string, content: string }>, notes: string[], todo: string[] }}
676
+ */
677
+ export function starterPlan({
678
+ branch = 'main',
679
+ workers = null,
680
+ withPackage = false,
681
+ readTarget,
682
+ workflows = [],
683
+ readTemplate,
684
+ }) {
685
+ const notes = [];
686
+ const todo = [];
687
+ const none = (note) => ({ files: [], notes: [...notes, note], todo: [] });
688
+ if (readTarget(CONFIG_PATH) !== null)
689
+ return none(
690
+ `${CONFIG_PATH} is already there, so --pipeline added nothing: run npx breakaway pipeline init in its checkout to render it.`,
691
+ );
692
+
693
+ let json = null;
694
+ try {
695
+ json = JSON.parse(readTarget('package.json') ?? 'null');
696
+ } catch {
697
+ /* said below, when it matters */
698
+ }
699
+ const scripts = isObject(json?.scripts) ? json.scripts : {};
700
+ let pkg = null;
701
+ if (withPackage) {
702
+ if (!isObject(json)) notes.push('There is no package.json to release, so there is no release flow.');
703
+ else if (json.private === true)
704
+ notes.push('package.json says "private": true, so npm won\'t take it: there is no release flow.');
705
+ else {
706
+ const name = typeof json.name === 'string' ? json.name : '';
707
+ const access = json.publishConfig?.access ?? (name.startsWith('@') ? 'restricted' : 'public');
708
+ pkg = { name, directory: '.', access };
709
+ try {
710
+ checkConfig({ checks: ['CI'], package: pkg }, { packageJson: () => json });
711
+ } catch (error) {
712
+ if (!(error instanceof PipelineError)) throw error;
713
+ notes.push(`No release flow: ${error.message}`);
714
+ pkg = null;
715
+ }
716
+ }
717
+ }
718
+ if (!workers && !pkg) return { files: [], notes, todo };
719
+
720
+ const checks = workflows.length
721
+ ? [...new Set(workflows.map((w) => pushCheckName(w.text)).filter((n) => n && !RENDERED_NAMES.has(n)))]
722
+ : ['CI'];
723
+ if (!checks.length)
724
+ return none(
725
+ `None of ${workflows.map((w) => w.path).join(', ')} runs on a push, so nothing would gate a deploy: add a check workflow that does, then run npx breakaway pipeline init in its checkout.`,
726
+ );
727
+
728
+ const manager = managerOf(readTarget);
729
+ const raw = {
730
+ ...(workers ? { workers } : {}),
731
+ ...(branch === 'main' ? {} : { branch }),
732
+ checks,
733
+ install: manager.install,
734
+ ...(typeof scripts.build === 'string' ? { build: `${manager.run} build` } : {}),
735
+ ...(workers ? { deployPaths: { [workers.production]: STARTER_PATHS } } : {}),
736
+ ...(pkg ? { package: pkg } : {}),
737
+ };
738
+ const config = checkConfig(raw, { packageJson: () => json });
739
+ const rendered = renderPipeline(config, readTemplate);
740
+ const there = rendered.filter((f) => readTarget(f.path) !== null).map((f) => f.path);
741
+ if (there.length)
742
+ return none(
743
+ `${there.join(', ')} ${there.length > 1 ? 'are' : 'is'} already there, so --pipeline added nothing: move ${there.length > 1 ? 'them' : 'it'} into ${CONFIG_PATH} and run npx breakaway pipeline init in its checkout.`,
744
+ );
745
+
746
+ const files = [
747
+ { path: CONFIG_PATH, content: `${JSON.stringify(raw, null, 2)}\n` },
748
+ ...rendered.map((f) => ({ path: f.path, content: f.text })),
749
+ ];
750
+ if (!workflows.length) {
751
+ const test = typeof scripts.test === 'string' && !NO_TEST.test(scripts.test) ? [`${manager.run} test`] : [];
752
+ const values = { ...valuesOf(config), ciHeader: CI_HEADER, test };
753
+ files.push({ path: CI_PATH, content: fill(readTemplate('ci.yml'), values) });
754
+ } else
755
+ todo.push(
756
+ `${CONFIG_PATH}: check that checks (${checks.join(', ')}) names only the workflows that must pass before a deploy or a release, and that none of the existing workflows deploys or publishes too`,
757
+ );
758
+ for (const file of files.filter((f) => f.path.endsWith('.yml'))) {
759
+ const problems = lintWorkflow(file.content);
760
+ if (problems.length) throw new Error(`${file.path} renders with problems: ${problems.join('; ')}`);
761
+ }
762
+
763
+ if (workers) {
764
+ todo.push(
765
+ `${CONFIG_PATH}: narrow deployPaths to the files the Worker is built from, and add a healthCheck address and any beforeDeploy commands (migrations)`,
766
+ `Before merging (the merge itself runs Deploy once CI passes): create the Workers ${workers.staging} and ${workers.production} and a Cloudflare API token for each; make the GitHub environments staging and production, restricted to ${branch}, each with its token as CLOUDFLARE_API_TOKEN, and the repository variable CLOUDFLARE_ACCOUNT_ID; give the board's GitHub App Actions: read and write here`,
767
+ );
768
+ }
769
+ if (pkg)
770
+ todo.push(
771
+ `Before merging (the merge itself stages the first pre-release of ${pkg.name}): make the GitHub environment npm, restricted to ${branch}; on npmjs.com add a trusted publisher for release.yml and that environment (or put a granular NPM_TOKEN that can't bypass 2FA in it); after it, approve each staged version on npm with 2FA`,
772
+ );
773
+ todo.push(
774
+ `After merging: turn deploys on from the repository's GitHub page on the board (Turn on deploys), once the workflows are on ${branch}`,
775
+ );
776
+ return { files, notes, todo };
777
+ }
778
+
618
779
  // ---- the command ---------------------------------------------------------------------------
619
780
 
620
781
  const TEMPLATES = join(dirname(fileURLToPath(import.meta.url)), '..', '..', 'template', 'pipeline');
package/scripts/tasks.mjs CHANGED
@@ -21,6 +21,7 @@ import {
21
21
  lstatSync,
22
22
  mkdirSync,
23
23
  readFileSync,
24
+ readdirSync,
24
25
  renameSync,
25
26
  rmSync,
26
27
  symlinkSync,
@@ -245,6 +246,11 @@ Working
245
246
  --never-share <text>; --defaults takes the default for the rest without asking
246
247
  --update refresh the copied files (the CLI, core, skill, Taskwarrior files) in a pull request when they're
247
248
  older than this checkout's; the repository's own (its prompt, AGENTS.md) are never touched
249
+ --pipeline also add the deploy flow: .github/breakaway-pipeline.json for the Workers --staging <name> and
250
+ --production <name> (asked in a terminal; default <slug>-staging and <slug>), what it renders,
251
+ and a minimal CI when the repository has no workflow. Only new files: never one it already has
252
+ --package also add the release flow for the npm package package.json names, with its publishConfig.access
253
+ (none for a private package.json); with --pipeline, both flows
248
254
  repos setup <slug|owner/name> the Add a repository wizard's steps for it: which are done, the one to do now, and
249
255
  any fix; read only, so an agent helping the owner may run it (the board's /#/add-repo)
250
256
  repos remove <slug> take one off the board (owner): its sync, webhooks, agents, and routines stop; its tasks
@@ -360,7 +366,10 @@ const FLAGS = new Set([
360
366
  'replace',
361
367
  'update',
362
368
  'defaults',
369
+ 'package',
363
370
  ]);
371
+ /** Flags only in repos init (BRK-91): --pipeline takes a file in repos modify, and is a flag there. */
372
+ const INIT_FLAGS = new Set(['pipeline']);
364
373
 
365
374
  function parse(argv) {
366
375
  const positional = [];
@@ -373,7 +382,7 @@ function parse(argv) {
373
382
  continue;
374
383
  }
375
384
  const [name, inline] = arg.slice(2).split(/=(.*)/su);
376
- if (FLAGS.has(name)) {
385
+ if (FLAGS.has(name) || (INIT_FLAGS.has(name) && positional[0] === 'repos' && positional[1] === 'init')) {
377
386
  opts[name] = true;
378
387
  continue;
379
388
  }
@@ -1810,6 +1819,11 @@ async function initRepo(slug) {
1810
1819
  const registry = await call('GET', 'repos');
1811
1820
  const repo = registry.repos.find((r) => r.slug === String(slug).toLowerCase());
1812
1821
  if (!repo) fail(unknownRepo(slug, registry.repos));
1822
+ const flows = Boolean(opts.pipeline || opts.package);
1823
+ if (flows && opts.update)
1824
+ fail(
1825
+ '--pipeline and --package add the deploy and release flows to a repository that has none. To refresh rendered ones, run npx breakaway pipeline init --update in its checkout.',
1826
+ );
1813
1827
  const dir = resolve(opts.dir ?? join(dirname(REPO), repo.slug));
1814
1828
  const git = (...a) =>
1815
1829
  execFileSync('git', ['-C', dir, ...a], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }).trim();
@@ -1881,6 +1895,36 @@ async function initRepo(slug) {
1881
1895
  update,
1882
1896
  ...promptSections(answers),
1883
1897
  });
1898
+ // The deploy flow, the package's release flow, and a minimal CI (BRK-91): new files in the same commit.
1899
+ let starter = { files: [] };
1900
+ if (flows) {
1901
+ const { PipelineError, starterPlan } = await import('./tasks/pipeline.js');
1902
+ const workers = opts.pipeline ? await workerNames(repo.slug) : null;
1903
+ const at = join(dir, '.github', 'workflows');
1904
+ const workflows = existsSync(at)
1905
+ ? readdirSync(at)
1906
+ .filter((name) => /\.ya?ml$/u.test(name))
1907
+ .map((name) => ({ path: `.github/workflows/${name}`, text: readFileSync(join(at, name), 'utf8') }))
1908
+ : [];
1909
+ // package.json as the commit leaves it: repos init adds one when the repository has none.
1910
+ const planned = (path) => plan.files.find((f) => f.path === path && !f.link)?.content ?? readTarget(path);
1911
+ try {
1912
+ starter = starterPlan({
1913
+ branch,
1914
+ workers,
1915
+ withPackage: Boolean(opts.package),
1916
+ readTarget: planned,
1917
+ workflows,
1918
+ readTemplate: (name) => readFileSync(join(PKG, 'template', 'pipeline', name), 'utf8'),
1919
+ });
1920
+ } catch (e) {
1921
+ if (!(e instanceof PipelineError)) throw e;
1922
+ fail(e.message);
1923
+ }
1924
+ plan.files.push(...starter.files);
1925
+ plan.notes.push(...starter.notes);
1926
+ plan.todo.push(...starter.todo);
1927
+ }
1884
1928
  const report = [
1885
1929
  `${repo.github} ${empty ? 'has no commits yet' : `has commits on ${branch}`}.`,
1886
1930
  ...(plan.files.length
@@ -1955,7 +1999,9 @@ async function initRepo(slug) {
1955
1999
  `git add failed in ${dir}: ${String(e.stderr || e.message).trim()}. Nothing was committed. Fix that, then run repos init again (it leaves what's there alone), or use git checkout ${repo.defaultBranch || 'main'} && git branch -D ${work} to start over.`,
1956
2000
  );
1957
2001
  }
1958
- const first = initCommitMessage(repo.slug);
2002
+ const first = initCommitMessage(repo.slug, {
2003
+ by: `npx breakaway repos init ${repo.slug}${opts.pipeline ? ' --pipeline' : ''}${opts.package ? ' --package' : ''}`,
2004
+ });
1959
2005
  const title = update ? "Update the task board's agent files" : first.title;
1960
2006
  git(
1961
2007
  'commit',
@@ -1964,7 +2010,7 @@ async function initRepo(slug) {
1964
2010
  '-m',
1965
2011
  update
1966
2012
  ? `The board's core, skill, release helpers, and Taskwarrior files as they are in ${board ?? 'breakaway'} now, and the session hooks run through npx, so an old copy of the CLI is removed: run it as npx ${CLI_PACKAGE} (CLI version ${CLI_VERSION}). This repository's own files are unchanged. Updated by npx ${CLI_PACKAGE} repos init ${repo.slug} --update.`
1967
- : first.body,
2013
+ : `${first.body}${starter.files.length ? ` It also adds the deploy and release flows, rendered from ${starter.files[0].path}.` : ''}`,
1968
2014
  );
1969
2015
  const pushed = spawnSync('git', ['-C', dir, 'push', '-u', 'origin', empty ? `HEAD:refs/heads/${branch}` : work], {
1970
2016
  stdio: 'inherit',
@@ -2009,6 +2055,28 @@ async function initRepo(slug) {
2009
2055
  console.log(['', ...next].join('\n'));
2010
2056
  }
2011
2057
 
2058
+ /**
2059
+ * The staging and production Workers for repos init --pipeline (BRK-91): --staging and --production, else asked in a
2060
+ * terminal, else `<slug>-staging` and `<slug>`.
2061
+ */
2062
+ async function workerNames(slug) {
2063
+ const names = { staging: opts.staging, production: opts.production };
2064
+ const defaults = { staging: `${slug}-staging`, production: slug };
2065
+ const ask = !opts.defaults && !opts['dry-run'] && !opts.json && process.stdin.isTTY;
2066
+ const { createInterface } = ask ? await import('node:readline') : {};
2067
+ for (const env of /** @type {const} */ (['staging', 'production'])) {
2068
+ if (names[env] !== undefined) continue;
2069
+ if (ask) {
2070
+ console.log(`\nThe ${env} Worker's name (Enter for ${defaults[env]}):`);
2071
+ names[env] = await askIn('> ', { input: process.stdin, output: process.stdout, createInterface }).catch((error) =>
2072
+ fail(error.message),
2073
+ );
2074
+ }
2075
+ names[env] = String(names[env] ?? '').trim() || defaults[env];
2076
+ }
2077
+ return { staging: String(names.staging), production: String(names.production) };
2078
+ }
2079
+
2012
2080
  /**
2013
2081
  * The agent prompt's sections for repos init (CLD-196), flag → text: each from its flag (--checks, --never-share, …),
2014
2082
  * and in a terminal, the rest asked one by one, with Enter taking the default. Without a terminal, with --defaults,
@@ -4,5 +4,5 @@
4
4
  * and how to update it. scripts/tasks/version.test.js fails when the copied files change and this doesn't:
5
5
  * so it lives in the board's package (CLD-135) and the CLI imports it from here.
6
6
  */
7
- export const CLI_VERSION = 69;
8
- export const CLI_FINGERPRINT = 'd7528500bc5e5a4c';
7
+ export const CLI_VERSION = 70;
8
+ export const CLI_FINGERPRINT = 'f3abfe0e5927ffed';
@@ -0,0 +1,40 @@
1
+ # {{ciHeader}}
2
+ #
3
+ # CI: install, build, and test every pull request and every push to {{branchName}}. Deploy and Release run only after
4
+ # it passes on {{branchName}}, so a change that breaks it never ships. This file is the repository's own: change its
5
+ # steps to taste, and if you rename it, change checks in .github/breakaway-pipeline.json to match.
6
+ name: CI
7
+
8
+ on:
9
+ push:
10
+ branches: [{{branch}}]
11
+ pull_request:
12
+
13
+ permissions: {}
14
+
15
+ concurrency:
16
+ group: ci-${{ github.ref }}
17
+ cancel-in-progress: true
18
+
19
+ jobs:
20
+ check:
21
+ name: check
22
+ runs-on: ubuntu-latest
23
+ timeout-minutes: 20
24
+ permissions:
25
+ contents: read
26
+ steps:
27
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
28
+ with:
29
+ persist-credentials: false
30
+ - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
31
+ with:
32
+ node-version: 22
33
+
34
+ - name: Install, build, and test
35
+ run: |
36
+ set -euo pipefail
37
+ if grep -q '"packageManager"' package.json 2>/dev/null; then corepack enable; fi
38
+ {{@install}}
39
+ {{@build}}
40
+ {{@test}}