agentic-team 0.0.0-stage → 6.1.0
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/LICENSE +21 -0
- package/README.md +184 -2
- package/bin/build-template.js +208 -0
- package/bin/index.js +1139 -0
- package/package.json +41 -4
- package/template/.claude-plugin/plugin.json +10 -0
- package/template/.orchestra/README.md +401 -0
- package/template/.orchestra/blueprints/README.md +87 -0
- package/template/.orchestra/blueprints/api-only.md +46 -0
- package/template/.orchestra/blueprints/component-crud-resource.md +25 -0
- package/template/.orchestra/blueprints/saas-starter.md +69 -0
- package/template/.orchestra/config.yml +277 -0
- package/template/.orchestra/roles/orchestrator.md +105 -0
- package/template/.orchestra/roles/product-manager.md +438 -0
- package/template/.orchestra/scripts/compact-diff.awk +45 -0
- package/template/.orchestra/stacks/README.md +81 -0
- package/template/.orchestra/stacks/_lib/common.sh +3095 -0
- package/template/.orchestra/stacks/_repo/setup.sh +1109 -0
- package/template/.orchestra/stacks/backend-hono/claude/commands/endpoint.md +13 -0
- package/template/.orchestra/stacks/backend-hono/claude/skills/api-endpoint/SKILL.md +111 -0
- package/template/.orchestra/stacks/backend-hono/claude/skills/testing/SKILL.md +75 -0
- package/template/.orchestra/stacks/backend-hono/setup.sh +5945 -0
- package/template/.orchestra/stacks/backend-hono/stack.md +195 -0
- package/template/.orchestra/stacks/mobile-android-native/claude/commands/component.md +13 -0
- package/template/.orchestra/stacks/mobile-android-native/claude/skills/design-component/SKILL.md +58 -0
- package/template/.orchestra/stacks/mobile-android-native/setup.sh +607 -0
- package/template/.orchestra/stacks/mobile-android-native/stack.md +138 -0
- package/template/.orchestra/stacks/mobile-expo/claude/commands/component.md +12 -0
- package/template/.orchestra/stacks/mobile-expo/claude/skills/design-component/SKILL.md +92 -0
- package/template/.orchestra/stacks/mobile-expo/setup.sh +470 -0
- package/template/.orchestra/stacks/mobile-expo/stack.md +141 -0
- package/template/.orchestra/stacks/mobile-ios-native/claude/commands/component.md +12 -0
- package/template/.orchestra/stacks/mobile-ios-native/claude/skills/design-component/SKILL.md +58 -0
- package/template/.orchestra/stacks/mobile-ios-native/setup.sh +468 -0
- package/template/.orchestra/stacks/mobile-ios-native/stack.md +130 -0
- package/template/.orchestra/stacks/web-nextjs/claude/commands/component.md +12 -0
- package/template/.orchestra/stacks/web-nextjs/claude/skills/design-component/SKILL.md +100 -0
- package/template/.orchestra/stacks/web-nextjs/claude/skills/design-tokens/SKILL.md +61 -0
- package/template/.orchestra/stacks/web-nextjs/setup.sh +128 -0
- package/template/.orchestra/stacks/web-nextjs/stack.md +170 -0
- package/template/.orchestra/stacks/web-vite-react/claude/commands/component.md +12 -0
- package/template/.orchestra/stacks/web-vite-react/claude/skills/design-component/SKILL.md +100 -0
- package/template/.orchestra/stacks/web-vite-react/claude/skills/design-tokens/SKILL.md +61 -0
- package/template/.orchestra/stacks/web-vite-react/setup.sh +185 -0
- package/template/.orchestra/stacks/web-vite-react/stack.md +177 -0
- package/template/.orchestra/trackers/github.sh +467 -0
- package/template/CLAUDE.md +54 -0
- package/template/agents/architect.md +83 -0
- package/template/agents/engineer-high.md +17 -0
- package/template/agents/engineer-low.md +17 -0
- package/template/agents/engineer-max.md +17 -0
- package/template/agents/engineer-medium.md +17 -0
- package/template/agents/engineer-xhigh.md +17 -0
- package/template/agents/lead.md +870 -0
- package/template/agents/milestone-reviewer.md +63 -0
- package/template/agents/orchestrator.md +105 -0
- package/template/agents/product-manager.md +438 -0
- package/template/agents/reviewer.md +301 -0
- package/template/agents/scaffolder.md +180 -0
- package/template/agents/tracker.md +202 -0
- package/template/commands/blueprint.md +23 -0
- package/template/commands/create-role.md +64 -0
- package/template/commands/export.md +45 -0
- package/template/commands/help.md +55 -0
- package/template/commands/hotfix.md +12 -0
- package/template/commands/orchestrator.md +12 -0
- package/template/commands/pm.md +18 -0
- package/template/commands/rewind.md +61 -0
- package/template/commands/setup.md +14 -0
- package/template/commands/start.md +29 -0
- package/template/commands/status.md +11 -0
- package/template/commands/sync.md +73 -0
- package/template/commands/verifier.md +53 -0
- package/template/hooks/audit-context.sh +153 -0
- package/template/hooks/enforce-role.sh +175 -0
- package/template/hooks/protect-system-files.sh +123 -0
- package/template/hooks/review-rounds.sh +172 -0
- package/template/rules/acceptance-check.orchestra.md +11 -0
- package/template/rules/analytics-kpi.orchestra.md +56 -0
- package/template/rules/code-standards.orchestra.md +34 -0
- package/template/rules/comments.orchestra.md +77 -0
- package/template/rules/commit-format.orchestra.md +20 -0
- package/template/rules/dependency-upgrade.orchestra.md +112 -0
- package/template/rules/design-parity.orchestra.md +170 -0
- package/template/rules/observability.orchestra.md +118 -0
- package/template/rules/performance.orchestra.md +186 -0
- package/template/rules/phase-limits.orchestra.md +3 -0
- package/template/rules/pull-request.orchestra.md +213 -0
- package/template/rules/role-boundaries.orchestra.md +117 -0
- package/template/rules/security.orchestra.md +106 -0
- package/template/rules/stuck-detection.orchestra.md +25 -0
- package/template/rules/testing-standards.orchestra.md +214 -0
- package/template/rules/tracker-authority.orchestra.md +190 -0
- package/template/rules/verification-gate.orchestra.md +56 -0
- package/template/skills/accessibility/SKILL.md +31 -0
- package/template/skills/android-testing/SKILL.md +112 -0
- package/template/skills/architecture/SKILL.md +175 -0
- package/template/skills/auth-setup/SKILL.md +105 -0
- package/template/skills/best-practices/SKILL.md +29 -0
- package/template/skills/code-optimizer/SKILL.md +27 -0
- package/template/skills/core-web-vitals/SKILL.md +32 -0
- package/template/skills/crud-api/SKILL.md +32 -0
- package/template/skills/debug/SKILL.md +28 -0
- package/template/skills/deployment/SKILL.md +104 -0
- package/template/skills/error-handling/SKILL.md +154 -0
- package/template/skills/frontend-design/SKILL.md +30 -0
- package/template/skills/fullstack-infrastructure/SKILL.md +108 -0
- package/template/skills/ios-testing/SKILL.md +100 -0
- package/template/skills/mobile-testing/SKILL.md +102 -0
- package/template/skills/observability/SKILL.md +91 -0
- package/template/skills/project-structure/SKILL.md +114 -0
- package/template/skills/push-notifications/SKILL.md +146 -0
- package/template/skills/react-best-practices/SKILL.md +31 -0
- package/template/skills/realtime/SKILL.md +146 -0
- package/template/skills/review/SKILL.md +82 -0
- package/template/skills/testing/SKILL.md +44 -0
- package/template/skills/web-quality-audit/SKILL.md +47 -0
- package/template/skills/web-testing/SKILL.md +120 -0
|
@@ -0,0 +1,1109 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# Repo-level configuration, shared by every stack. Run once, after the app
|
|
3
|
+
# scaffolds, with the app directory names as arguments.
|
|
4
|
+
#
|
|
5
|
+
# These files are what turn the conventions from advice into enforcement.
|
|
6
|
+
# Without them the rules are prose an agent may or may not follow.
|
|
7
|
+
set -euo pipefail
|
|
8
|
+
|
|
9
|
+
LIB="$(cd "$(dirname "${BASH_SOURCE[0]}")/../_lib" && pwd)"
|
|
10
|
+
# shellcheck source=../_lib/common.sh
|
|
11
|
+
source "$LIB/common.sh"
|
|
12
|
+
|
|
13
|
+
MODE="standalone"
|
|
14
|
+
case "${1:-}" in
|
|
15
|
+
--standalone) MODE="standalone"; shift ;;
|
|
16
|
+
--workspace) MODE="workspace"; shift ;;
|
|
17
|
+
esac
|
|
18
|
+
STANDALONE=0
|
|
19
|
+
[ "$MODE" = "standalone" ] && STANDALONE=1
|
|
20
|
+
|
|
21
|
+
# Everything below writes repo-relative paths, and `git config core.hooksPath`
|
|
22
|
+
# resolves from the repo root. Running from a subdirectory would scatter config
|
|
23
|
+
# where git never looks — hooks configured, hooks never fire, no error.
|
|
24
|
+
REPO_ROOT="$(git rev-parse --show-toplevel 2>/dev/null)" || die "not inside a git repository"
|
|
25
|
+
cd "$REPO_ROOT"
|
|
26
|
+
|
|
27
|
+
APPS=("$@")
|
|
28
|
+
[ ${#APPS[@]} -gt 0 ] || die "usage: setup.sh [--standalone|--workspace] <app-dir> [app-dir...]"
|
|
29
|
+
|
|
30
|
+
mkdir -p .github/workflows
|
|
31
|
+
|
|
32
|
+
# ── CI ────────────────────────────────────────────────────────────────────
|
|
33
|
+
# The verification gate only means something if it also runs on the PR.
|
|
34
|
+
# Locally it can be skipped; here it cannot.
|
|
35
|
+
#
|
|
36
|
+
# Not uniform: an app with verify.sh runs that (native toolchains), and iOS
|
|
37
|
+
# needs a macOS runner. A single npm matrix would leave native apps
|
|
38
|
+
# permanently red on the check we ask you to make required.
|
|
39
|
+
{
|
|
40
|
+
cat <<'YML'
|
|
41
|
+
name: CI
|
|
42
|
+
|
|
43
|
+
on:
|
|
44
|
+
pull_request:
|
|
45
|
+
push:
|
|
46
|
+
branches: [main]
|
|
47
|
+
|
|
48
|
+
concurrency:
|
|
49
|
+
group: ci-${{ github.ref }}
|
|
50
|
+
cancel-in-progress: true
|
|
51
|
+
|
|
52
|
+
jobs:
|
|
53
|
+
verify:
|
|
54
|
+
runs-on: ${{ matrix.os }}
|
|
55
|
+
strategy:
|
|
56
|
+
fail-fast: false
|
|
57
|
+
matrix:
|
|
58
|
+
include:
|
|
59
|
+
YML
|
|
60
|
+
for a in "${APPS[@]}"; do
|
|
61
|
+
os="ubuntu-latest"
|
|
62
|
+
kind="node"
|
|
63
|
+
browser="no"
|
|
64
|
+
if [ -f "$a/verify.sh" ]; then
|
|
65
|
+
kind="script"
|
|
66
|
+
case "$a" in *ios*|*Ios*|*iOS*) os="macos-latest" ;; esac
|
|
67
|
+
[ -f "$a/project.yml" ] && os="macos-latest"
|
|
68
|
+
elif [ -f "$a/package.json" ] && grep -q '@vitest/browser' "$a/package.json"; then
|
|
69
|
+
# Vitest browser mode launches a real browser, which the runner image
|
|
70
|
+
# does not ship.
|
|
71
|
+
browser="yes"
|
|
72
|
+
fi
|
|
73
|
+
printf ' - { app: %s, os: %s, kind: %s, browser: %s }\n' "$a" "$os" "$kind" "$browser"
|
|
74
|
+
done
|
|
75
|
+
cat <<'YML'
|
|
76
|
+
name: ${{ matrix.app }}
|
|
77
|
+
defaults:
|
|
78
|
+
run:
|
|
79
|
+
working-directory: ${{ matrix.app }}
|
|
80
|
+
env:
|
|
81
|
+
# Matches the app's docker-compose.yml. Harmless where there is no
|
|
82
|
+
# database — it is one unused variable.
|
|
83
|
+
DATABASE_URL: postgres://postgres:postgres@localhost:55432/app
|
|
84
|
+
steps:
|
|
85
|
+
- uses: actions/checkout@__CHECKOUT_REF__
|
|
86
|
+
with:
|
|
87
|
+
# The diff-coverage and mutation gates diff against the PR base.
|
|
88
|
+
# A shallow clone has no base commit to diff against. gitleaks
|
|
89
|
+
# needs the full history for the same reason — a secret committed
|
|
90
|
+
# and then removed is still in the history, and still leaked.
|
|
91
|
+
fetch-depth: 0
|
|
92
|
+
|
|
93
|
+
# corepack must come first: setup-node's yarn cache shells out to yarn,
|
|
94
|
+
# and without corepack that resolves to the runner's global yarn 1.x,
|
|
95
|
+
# which refuses a project pinned to yarn 4.
|
|
96
|
+
- name: Enable corepack
|
|
97
|
+
if: matrix.kind == 'node'
|
|
98
|
+
run: corepack enable
|
|
99
|
+
|
|
100
|
+
- uses: actions/setup-node@__SETUP_NODE_REF__
|
|
101
|
+
if: matrix.kind == 'node'
|
|
102
|
+
with:
|
|
103
|
+
node-version-file: ${{ matrix.app }}/.node-version
|
|
104
|
+
cache: yarn
|
|
105
|
+
# setup-node resolves this from the repo root, not from
|
|
106
|
+
# working-directory. Listing both covers a lockfile per app
|
|
107
|
+
# (standalone) and a single root lockfile (workspace); it only
|
|
108
|
+
# errors when neither matches.
|
|
109
|
+
cache-dependency-path: |
|
|
110
|
+
yarn.lock
|
|
111
|
+
${{ matrix.app }}/yarn.lock
|
|
112
|
+
|
|
113
|
+
# --immutable is the point: CI fails rather than silently resolving a
|
|
114
|
+
# dependency the lockfile does not describe. A workspace keeps its one
|
|
115
|
+
# lockfile at the root, so install where it actually lives.
|
|
116
|
+
- name: Install
|
|
117
|
+
if: matrix.kind == 'node'
|
|
118
|
+
run: |
|
|
119
|
+
if [ -f yarn.lock ]; then yarn install --immutable
|
|
120
|
+
else (cd "$GITHUB_WORKSPACE" && yarn install --immutable); fi
|
|
121
|
+
|
|
122
|
+
- name: Install browser
|
|
123
|
+
if: matrix.browser == 'yes'
|
|
124
|
+
run: yarn playwright install --with-deps chromium
|
|
125
|
+
|
|
126
|
+
# The app's own compose file rather than a job-level `services:` block:
|
|
127
|
+
# services cannot be made conditional, and this job's matrix includes
|
|
128
|
+
# macOS runners for the native apps, where service containers do not
|
|
129
|
+
# exist. Reusing compose also means the postgres version is pinned in
|
|
130
|
+
# one place and CI runs the same path a developer does.
|
|
131
|
+
- name: Database
|
|
132
|
+
if: matrix.kind == 'node'
|
|
133
|
+
run: |
|
|
134
|
+
if [ -f docker-compose.yml ]; then
|
|
135
|
+
docker compose up -d --wait
|
|
136
|
+
else
|
|
137
|
+
echo "no docker-compose.yml in this app — skipped"
|
|
138
|
+
fi
|
|
139
|
+
|
|
140
|
+
- name: Verify
|
|
141
|
+
if: matrix.kind == 'node'
|
|
142
|
+
run: |
|
|
143
|
+
yarn typecheck
|
|
144
|
+
yarn test
|
|
145
|
+
yarn lint
|
|
146
|
+
yarn test:coverage
|
|
147
|
+
# A typecheck is not a build. tsc accepts an app that fails to
|
|
148
|
+
# bundle, and nothing else here loads the bundler.
|
|
149
|
+
if yarn run --silent 2>/dev/null | grep -qx 'build'; then yarn build; fi
|
|
150
|
+
# A known critical vulnerability blocks the merge.
|
|
151
|
+
yarn npm audit --severity critical
|
|
152
|
+
|
|
153
|
+
# The built app, driven in a real browser. Component tests mount one
|
|
154
|
+
# component in isolation and a typecheck never loads the bundler, so a
|
|
155
|
+
# broken route or a bundle that throws on load reaches this step and
|
|
156
|
+
# nothing before it. Guarded on the script being playwright's — an Expo
|
|
157
|
+
# app's test:e2e is maestro, and belongs in mobile-e2e.yml.
|
|
158
|
+
- name: End-to-end
|
|
159
|
+
if: matrix.kind == 'node'
|
|
160
|
+
run: |
|
|
161
|
+
if node -e 'const s=require("./package.json").scripts||{};process.exit(/playwright/.test(s["test:e2e"]||"")?0:1)'; then
|
|
162
|
+
yarn test:e2e
|
|
163
|
+
else
|
|
164
|
+
echo "no playwright e2e in this app — skipped"
|
|
165
|
+
fi
|
|
166
|
+
|
|
167
|
+
- uses: actions/upload-artifact@__UPLOAD_REF__
|
|
168
|
+
if: failure() && matrix.kind == 'node'
|
|
169
|
+
with:
|
|
170
|
+
name: playwright-${{ matrix.app }}
|
|
171
|
+
path: |
|
|
172
|
+
${{ matrix.app }}/playwright-report/
|
|
173
|
+
${{ matrix.app }}/test-results/
|
|
174
|
+
if-no-files-found: ignore
|
|
175
|
+
|
|
176
|
+
# Two commands that see what typecheck, lint and jest cannot: a native
|
|
177
|
+
# module off its SDK's compatibility table. Cheap, and the only thing in
|
|
178
|
+
# CI that catches the defect class that passes every JS test and then
|
|
179
|
+
# fails on a device.
|
|
180
|
+
- name: Native dependency check
|
|
181
|
+
if: matrix.kind == 'node'
|
|
182
|
+
run: |
|
|
183
|
+
if yarn run --silent 2>/dev/null | grep -qx 'doctor'; then
|
|
184
|
+
yarn doctor
|
|
185
|
+
else
|
|
186
|
+
echo "no doctor script in this app — skipped"
|
|
187
|
+
fi
|
|
188
|
+
|
|
189
|
+
# The gate that asks about this change rather than the repo's history:
|
|
190
|
+
# branch coverage of the lines this PR touched. Only meaningful on a PR,
|
|
191
|
+
# because only a PR has a base to diff against.
|
|
192
|
+
- name: Coverage of changed lines
|
|
193
|
+
if: matrix.kind == 'node' && github.event_name == 'pull_request'
|
|
194
|
+
env:
|
|
195
|
+
DIFF_COVERAGE_BASE: origin/${{ github.base_ref }}
|
|
196
|
+
run: |
|
|
197
|
+
if yarn run --silent 2>/dev/null | grep -q 'test:coverage:diff'; then
|
|
198
|
+
yarn test:coverage:diff
|
|
199
|
+
else
|
|
200
|
+
echo "no test:coverage:diff script in this app — skipped"
|
|
201
|
+
fi
|
|
202
|
+
|
|
203
|
+
# Stryker caches its results here; without the cache every PR pays for
|
|
204
|
+
# a cold run.
|
|
205
|
+
- name: Restore mutation cache
|
|
206
|
+
if: matrix.kind == 'node' && github.event_name == 'pull_request'
|
|
207
|
+
uses: actions/cache@__CACHE_REF__
|
|
208
|
+
with:
|
|
209
|
+
path: ${{ matrix.app }}/reports
|
|
210
|
+
key: stryker-${{ matrix.app }}-${{ github.sha }}
|
|
211
|
+
restore-keys: stryker-${{ matrix.app }}-
|
|
212
|
+
|
|
213
|
+
# Coverage says the line ran. This says it was checked.
|
|
214
|
+
- name: Mutation testing (changed files)
|
|
215
|
+
if: matrix.kind == 'node' && github.event_name == 'pull_request'
|
|
216
|
+
env:
|
|
217
|
+
MUTATION_BASE: origin/${{ github.base_ref }}
|
|
218
|
+
run: |
|
|
219
|
+
if yarn run --silent 2>/dev/null | grep -q 'test:mutation:diff'; then
|
|
220
|
+
yarn test:mutation:diff
|
|
221
|
+
else
|
|
222
|
+
echo "no test:mutation:diff script in this app — skipped"
|
|
223
|
+
fi
|
|
224
|
+
|
|
225
|
+
- name: Verify (native)
|
|
226
|
+
if: matrix.kind == 'script'
|
|
227
|
+
run: ./verify.sh
|
|
228
|
+
YML
|
|
229
|
+
} > .github/workflows/ci.yml
|
|
230
|
+
|
|
231
|
+
# Resolved now, not written from memory. The tokens above exist because the
|
|
232
|
+
# workflow heredoc is quoted — it has to be, ${{ ... }} is GitHub's syntax,
|
|
233
|
+
# not bash's — so the refs are substituted after the file is written.
|
|
234
|
+
CHECKOUT_REF="$(latest_action_ref actions/checkout v5)"
|
|
235
|
+
SETUP_NODE_REF="$(latest_action_ref actions/setup-node v5)"
|
|
236
|
+
CACHE_REF="$(latest_action_ref actions/cache v4)"
|
|
237
|
+
UPLOAD_ARTIFACT_REF="$(latest_action_ref actions/upload-artifact v4)"
|
|
238
|
+
sed -i.bak \
|
|
239
|
+
-e "s|@__CHECKOUT_REF__|@${CHECKOUT_REF}|" \
|
|
240
|
+
-e "s|@__SETUP_NODE_REF__|@${SETUP_NODE_REF}|" \
|
|
241
|
+
-e "s|@__CACHE_REF__|@${CACHE_REF}|" \
|
|
242
|
+
-e "s|@__UPLOAD_REF__|@${UPLOAD_ARTIFACT_REF}|" \
|
|
243
|
+
.github/workflows/ci.yml
|
|
244
|
+
rm -f .github/workflows/ci.yml.bak
|
|
245
|
+
log "ci workflow (${#APPS[@]} apps) — checkout@${CHECKOUT_REF}, setup-node@${SETUP_NODE_REF}, cache@${CACHE_REF}"
|
|
246
|
+
|
|
247
|
+
# ── Secret scanning ───────────────────────────────────────────────────────
|
|
248
|
+
# The one control on the list that nothing enforced. A committed secret is
|
|
249
|
+
# compromised the moment it is pushed — removing the commit is not the fix,
|
|
250
|
+
# rotating the credential is — so this has to fail before the merge, not
|
|
251
|
+
# after someone notices.
|
|
252
|
+
GITLEAKS_REF="$(latest_action_ref gitleaks/gitleaks-action v2)"
|
|
253
|
+
cat > .github/workflows/secrets.yml <<YML
|
|
254
|
+
name: secrets
|
|
255
|
+
|
|
256
|
+
on:
|
|
257
|
+
pull_request:
|
|
258
|
+
push:
|
|
259
|
+
branches: [main]
|
|
260
|
+
|
|
261
|
+
jobs:
|
|
262
|
+
gitleaks:
|
|
263
|
+
runs-on: ubuntu-latest
|
|
264
|
+
steps:
|
|
265
|
+
- uses: actions/checkout@${CHECKOUT_REF}
|
|
266
|
+
with:
|
|
267
|
+
# A secret that was committed and later deleted is still leaked.
|
|
268
|
+
# Scanning only the tip would miss every one of those.
|
|
269
|
+
fetch-depth: 0
|
|
270
|
+
|
|
271
|
+
- uses: gitleaks/gitleaks-action@${GITLEAKS_REF}
|
|
272
|
+
env:
|
|
273
|
+
GITHUB_TOKEN: \${{ secrets.GITHUB_TOKEN }}
|
|
274
|
+
YML
|
|
275
|
+
log "secret scanning workflow (gitleaks@${GITLEAKS_REF}, full history)"
|
|
276
|
+
|
|
277
|
+
# ── Dependabot ────────────────────────────────────────────────────────────
|
|
278
|
+
# This is the mechanism the whole testing rule exists to serve: a bump
|
|
279
|
+
# arrives as a PR, CI runs the suite, green means safe to merge.
|
|
280
|
+
{
|
|
281
|
+
cat <<'YML'
|
|
282
|
+
version: 2
|
|
283
|
+
updates:
|
|
284
|
+
- package-ecosystem: github-actions
|
|
285
|
+
directory: /
|
|
286
|
+
schedule:
|
|
287
|
+
interval: weekly
|
|
288
|
+
commit-message:
|
|
289
|
+
prefix: chore
|
|
290
|
+
YML
|
|
291
|
+
for a in "${APPS[@]}"; do
|
|
292
|
+
if [ -f "$a/package.json" ]; then
|
|
293
|
+
eco="npm"
|
|
294
|
+
elif [ -f "$a/build.gradle.kts" ] || [ -f "$a/settings.gradle.kts" ]; then
|
|
295
|
+
eco="gradle"
|
|
296
|
+
elif [ -f "$a/Package.swift" ]; then
|
|
297
|
+
# Only real SwiftPM manifests. An XcodeGen project.yml is not one, and
|
|
298
|
+
# dependabot's swift ecosystem errors on every run without it.
|
|
299
|
+
eco="swift"
|
|
300
|
+
else
|
|
301
|
+
warn "no recognised ecosystem in $a — skipping dependabot entry"
|
|
302
|
+
continue
|
|
303
|
+
fi
|
|
304
|
+
# "." must map to "/", not "/."
|
|
305
|
+
dir="/${a}"; [ "$a" = "." ] && dir="/"
|
|
306
|
+
cat <<YML
|
|
307
|
+
|
|
308
|
+
- package-ecosystem: ${eco}
|
|
309
|
+
directory: ${dir}
|
|
310
|
+
schedule:
|
|
311
|
+
interval: weekly
|
|
312
|
+
open-pull-requests-limit: 10
|
|
313
|
+
commit-message:
|
|
314
|
+
prefix: chore
|
|
315
|
+
include: scope
|
|
316
|
+
groups:
|
|
317
|
+
minor-and-patch:
|
|
318
|
+
update-types: [minor, patch]
|
|
319
|
+
YML
|
|
320
|
+
# Expo is the exception to "green CI means merge". The SDK pins react,
|
|
321
|
+
# react-native and every native module, and `expo install` resolves
|
|
322
|
+
# against that table — so a bump to the registry's latest installs a
|
|
323
|
+
# version the SDK rejects. Dependabot cannot know that, would file the
|
|
324
|
+
# same PR every week, and merging one breaks the build. The SDK upgrade
|
|
325
|
+
# itself is the event that moves these, via `expo install --fix`.
|
|
326
|
+
if [ "$eco" = "npm" ] && node -e 'const p=require(process.argv[1]);const d={...p.dependencies,...p.devDependencies};process.exit(d.expo?0:1)' "$PWD/$a/package.json" 2>/dev/null; then
|
|
327
|
+
cat <<'YML'
|
|
328
|
+
ignore:
|
|
329
|
+
# Managed by the Expo SDK — move these with `expo install --fix`.
|
|
330
|
+
- dependency-name: expo
|
|
331
|
+
- dependency-name: expo-*
|
|
332
|
+
- dependency-name: react
|
|
333
|
+
- dependency-name: react-dom
|
|
334
|
+
- dependency-name: react-native
|
|
335
|
+
- dependency-name: react-native-*
|
|
336
|
+
- dependency-name: "@react-native/*"
|
|
337
|
+
- dependency-name: "@react-native-*/*"
|
|
338
|
+
- dependency-name: "@sentry/react-native"
|
|
339
|
+
# jest-expo depends on the jest 29 packages throughout; jest 30 splits
|
|
340
|
+
# jest-runtime from jest-mock and every suite fails.
|
|
341
|
+
- dependency-name: jest
|
|
342
|
+
- dependency-name: jest-expo
|
|
343
|
+
- dependency-name: "@types/jest"
|
|
344
|
+
- dependency-name: typescript
|
|
345
|
+
YML
|
|
346
|
+
# This whole block redirects stdout into dependabot.yml — log to stderr.
|
|
347
|
+
log "dependabot: expo app in $a — SDK-managed packages ignored" >&2
|
|
348
|
+
fi
|
|
349
|
+
done
|
|
350
|
+
} > .github/dependabot.yml
|
|
351
|
+
log "dependabot config"
|
|
352
|
+
|
|
353
|
+
# ── Mobile e2e ────────────────────────────────────────────────────────────
|
|
354
|
+
# Separate from ci.yml on purpose. This one builds a native app and boots an
|
|
355
|
+
# emulator; it takes fifteen minutes where the rest of CI takes two, and it
|
|
356
|
+
# only has anything to say when the mobile app changed.
|
|
357
|
+
#
|
|
358
|
+
# It is the layer that is not optional on mobile: jest-expo renders components
|
|
359
|
+
# in Node against mocked native modules, so a missing native dependency or an
|
|
360
|
+
# SDK mismatch passes the entire JS suite and fails on a device.
|
|
361
|
+
EXPO_APP=""
|
|
362
|
+
for a in "${APPS[@]}"; do
|
|
363
|
+
if [ -f "$a/package.json" ] && node -e 'const p=require(process.argv[1]);const d={...p.dependencies,...p.devDependencies};process.exit(d.expo?0:1)' "$PWD/$a/package.json" 2>/dev/null; then
|
|
364
|
+
EXPO_APP="$a"
|
|
365
|
+
break
|
|
366
|
+
fi
|
|
367
|
+
done
|
|
368
|
+
|
|
369
|
+
if [ -n "$EXPO_APP" ]; then
|
|
370
|
+
SETUP_JAVA_REF="$(latest_action_ref actions/setup-java v5)"
|
|
371
|
+
EMULATOR_REF="$(latest_action_ref reactivecircus/android-emulator-runner v2)"
|
|
372
|
+
UPLOAD_REF="$(latest_action_ref actions/upload-artifact v4)"
|
|
373
|
+
{
|
|
374
|
+
cat <<YML
|
|
375
|
+
name: mobile e2e
|
|
376
|
+
|
|
377
|
+
# Android on every pull request that touches the app — an ubuntu runner is
|
|
378
|
+
# cheap. iOS on main, because a macOS runner costs roughly ten times as much
|
|
379
|
+
# per minute and the two rarely diverge on the same day.
|
|
380
|
+
on:
|
|
381
|
+
pull_request:
|
|
382
|
+
paths: ["${EXPO_APP}/**"]
|
|
383
|
+
push:
|
|
384
|
+
branches: [main]
|
|
385
|
+
|
|
386
|
+
concurrency:
|
|
387
|
+
group: mobile-e2e-\${{ github.ref }}
|
|
388
|
+
cancel-in-progress: true
|
|
389
|
+
|
|
390
|
+
jobs:
|
|
391
|
+
android:
|
|
392
|
+
runs-on: ubuntu-latest
|
|
393
|
+
timeout-minutes: 45
|
|
394
|
+
defaults:
|
|
395
|
+
run:
|
|
396
|
+
working-directory: ${EXPO_APP}
|
|
397
|
+
env:
|
|
398
|
+
# A debug e2e build has no Sentry credentials and must not fail for
|
|
399
|
+
# want of them. Sentry's Xcode phase gates on this exact variable and
|
|
400
|
+
# kills the build with exit 65 otherwise.
|
|
401
|
+
SENTRY_DISABLE_AUTO_UPLOAD: "true"
|
|
402
|
+
steps:
|
|
403
|
+
- uses: actions/checkout@${CHECKOUT_REF}
|
|
404
|
+
|
|
405
|
+
- run: corepack enable
|
|
406
|
+
working-directory: .
|
|
407
|
+
|
|
408
|
+
- uses: actions/setup-node@${SETUP_NODE_REF}
|
|
409
|
+
with:
|
|
410
|
+
node-version-file: ${EXPO_APP}/.node-version
|
|
411
|
+
cache: yarn
|
|
412
|
+
cache-dependency-path: |
|
|
413
|
+
yarn.lock
|
|
414
|
+
${EXPO_APP}/yarn.lock
|
|
415
|
+
|
|
416
|
+
- uses: actions/setup-java@${SETUP_JAVA_REF}
|
|
417
|
+
with:
|
|
418
|
+
distribution: temurin
|
|
419
|
+
# 21, not AGP's minimum of 17: robolectric's stubbed jar for a
|
|
420
|
+
# recent API level refuses to start on 17 with "Android SDK 36
|
|
421
|
+
# requires Java 21". AGP accepts anything at or above 17.
|
|
422
|
+
java-version: "21"
|
|
423
|
+
|
|
424
|
+
- name: Install
|
|
425
|
+
run: |
|
|
426
|
+
if [ -f yarn.lock ]; then yarn install --immutable
|
|
427
|
+
else (cd "\$GITHUB_WORKSPACE" && yarn install --immutable); fi
|
|
428
|
+
|
|
429
|
+
# Cheap, and it fails before a fifteen-minute build when a native
|
|
430
|
+
# One script, not a second copy of these steps — the same reasoning as
|
|
431
|
+
# the ios job below. Two phases because the emulator is the action's to
|
|
432
|
+
# own: the build happens before it boots, the test inside it.
|
|
433
|
+
- name: Build
|
|
434
|
+
run: ./e2e-android.sh build
|
|
435
|
+
|
|
436
|
+
- name: Install maestro
|
|
437
|
+
run: |
|
|
438
|
+
curl -fsSL https://get.maestro.mobile.dev | bash
|
|
439
|
+
echo "\$HOME/.maestro/bin" >> "\$GITHUB_PATH"
|
|
440
|
+
|
|
441
|
+
- uses: reactivecircus/android-emulator-runner@${EMULATOR_REF}
|
|
442
|
+
with:
|
|
443
|
+
api-level: 34
|
|
444
|
+
arch: x86_64
|
|
445
|
+
working-directory: ${EXPO_APP}
|
|
446
|
+
script: ./e2e-android.sh test
|
|
447
|
+
|
|
448
|
+
# Maestro writes its failure output here, and reading it is the only
|
|
449
|
+
# way to tell a broken flow from a broken app.
|
|
450
|
+
- uses: actions/upload-artifact@${UPLOAD_REF}
|
|
451
|
+
if: failure()
|
|
452
|
+
with:
|
|
453
|
+
name: maestro-android
|
|
454
|
+
path: ~/.maestro/tests/
|
|
455
|
+
if-no-files-found: ignore
|
|
456
|
+
|
|
457
|
+
ios:
|
|
458
|
+
# macOS minutes are expensive; main only.
|
|
459
|
+
if: github.event_name == 'push'
|
|
460
|
+
runs-on: macos-latest
|
|
461
|
+
timeout-minutes: 60
|
|
462
|
+
defaults:
|
|
463
|
+
run:
|
|
464
|
+
working-directory: ${EXPO_APP}
|
|
465
|
+
env:
|
|
466
|
+
# A debug e2e build has no Sentry credentials and must not fail for
|
|
467
|
+
# want of them. Sentry's Xcode phase gates on this exact variable and
|
|
468
|
+
# kills the build with exit 65 otherwise.
|
|
469
|
+
SENTRY_DISABLE_AUTO_UPLOAD: "true"
|
|
470
|
+
# CocoaPods reads the Podfile through ruby's default external encoding.
|
|
471
|
+
# With no locale set that is US-ASCII, and `pod install` dies inside
|
|
472
|
+
# String#unicode_normalize with "Unicode Normalization not appropriate
|
|
473
|
+
# for ASCII-8BIT" — then xcodebuild fails with the far less helpful
|
|
474
|
+
# "sandbox is not in sync with the Podfile.lock". Reproduced on a real
|
|
475
|
+
# machine; a runner image that happens to set a locale is the image's
|
|
476
|
+
# choice, not ours.
|
|
477
|
+
LANG: en_US.UTF-8
|
|
478
|
+
steps:
|
|
479
|
+
- uses: actions/checkout@${CHECKOUT_REF}
|
|
480
|
+
|
|
481
|
+
- run: corepack enable
|
|
482
|
+
working-directory: .
|
|
483
|
+
|
|
484
|
+
- uses: actions/setup-node@${SETUP_NODE_REF}
|
|
485
|
+
with:
|
|
486
|
+
node-version-file: ${EXPO_APP}/.node-version
|
|
487
|
+
cache: yarn
|
|
488
|
+
cache-dependency-path: |
|
|
489
|
+
yarn.lock
|
|
490
|
+
${EXPO_APP}/yarn.lock
|
|
491
|
+
|
|
492
|
+
# maestro is a JVM application. The android job gets a JDK incidentally,
|
|
493
|
+
# because gradle needs one; here nothing else does, so without this step
|
|
494
|
+
# `maestro test` dies on "Unable to locate a Java Runtime" after the
|
|
495
|
+
# build has already been paid for. A macOS runner image happens to ship
|
|
496
|
+
# a JDK today — that is the image's choice, not ours.
|
|
497
|
+
- uses: actions/setup-java@${SETUP_JAVA_REF}
|
|
498
|
+
with:
|
|
499
|
+
distribution: temurin
|
|
500
|
+
java-version: "21"
|
|
501
|
+
|
|
502
|
+
- name: Install
|
|
503
|
+
run: |
|
|
504
|
+
if [ -f yarn.lock ]; then yarn install --immutable
|
|
505
|
+
else (cd "\$GITHUB_WORKSPACE" && yarn install --immutable); fi
|
|
506
|
+
|
|
507
|
+
# One script, not a second copy of these steps. It carries the
|
|
508
|
+
# locale, the Sentry opt-out, the JDK check maestro needs and the
|
|
509
|
+
# Release/--no-bundler reasoning — see the file. Anyone can reproduce a
|
|
510
|
+
# red e2e by running it, and the orchestrator's own CI runs the same
|
|
511
|
+
# thing rather than a paraphrase of it.
|
|
512
|
+
- name: e2e
|
|
513
|
+
run: ./e2e-ios.sh
|
|
514
|
+
|
|
515
|
+
|
|
516
|
+
# Same reason as the android job: without the run output there is no
|
|
517
|
+
# way to tell a broken flow from a broken app.
|
|
518
|
+
- uses: actions/upload-artifact@${UPLOAD_REF}
|
|
519
|
+
if: failure()
|
|
520
|
+
with:
|
|
521
|
+
name: maestro-ios
|
|
522
|
+
path: ~/.maestro/tests/
|
|
523
|
+
if-no-files-found: ignore
|
|
524
|
+
YML
|
|
525
|
+
} > .github/workflows/mobile-e2e.yml
|
|
526
|
+
log "mobile e2e workflow (android per PR, ios on main) for ${EXPO_APP}"
|
|
527
|
+
warn "mobile-e2e.yml has never run here — it needs a real emulator and a"
|
|
528
|
+
warn "native build. Expect to tune signing and the emulator image on its"
|
|
529
|
+
warn "first run, and fill in .maestro/core-flow.yaml before trusting it."
|
|
530
|
+
fi
|
|
531
|
+
|
|
532
|
+
# ── Dependency policy, as automation ──────────────────────────────────────
|
|
533
|
+
# The rule says: minor and patch green is a merge, a major gets a human. That
|
|
534
|
+
# only holds if something does the merging — otherwise the PRs pile up and
|
|
535
|
+
# get merged in a batch without anyone reading the majors either.
|
|
536
|
+
#
|
|
537
|
+
# And the other half of the rule — never loosen a test to make a dependency
|
|
538
|
+
# PR green — is exactly what a bot cannot be trusted with, so it is checked
|
|
539
|
+
# rather than asked for.
|
|
540
|
+
# `|| true`: with pipefail, a missing config.yml makes the whole pipeline
|
|
541
|
+
# fail and set -e takes the script down with it. Absent config means the
|
|
542
|
+
# default, not an error.
|
|
543
|
+
# awk, not `grep -A<n>`: the shipped config.yml carries three comment lines
|
|
544
|
+
# between `dependencies:` and `automerge:`, so a fixed window missed the key
|
|
545
|
+
# entirely and every repo silently got "none" while its config said otherwise.
|
|
546
|
+
# This reads to the next top-level key instead, so comments cannot defeat it.
|
|
547
|
+
AUTOMERGE="$(awk '/^ dependencies:/{f=1;next} f && /^[a-z]/{exit} f && /^[[:space:]]*automerge:/{print $2;exit}' "$(git rev-parse --show-toplevel)/.orchestra/config.yml" 2>/dev/null || true)"
|
|
548
|
+
AUTOMERGE="${AUTOMERGE:-none}"
|
|
549
|
+
METADATA_REF="$(latest_action_ref dependabot/fetch-metadata v2)"
|
|
550
|
+
|
|
551
|
+
cat > .github/workflows/dependabot.yml <<YML
|
|
552
|
+
name: dependabot
|
|
553
|
+
|
|
554
|
+
on: pull_request
|
|
555
|
+
|
|
556
|
+
permissions:
|
|
557
|
+
contents: write
|
|
558
|
+
pull-requests: write
|
|
559
|
+
|
|
560
|
+
jobs:
|
|
561
|
+
# The half a bot must not decide for itself. A dependency bump that needed a
|
|
562
|
+
# test changed is a behaviour change wearing a version number — the test was
|
|
563
|
+
# doing its job, and the fix belongs in our code or the upgrade waits.
|
|
564
|
+
no-loosened-tests:
|
|
565
|
+
if: github.actor == 'dependabot[bot]'
|
|
566
|
+
runs-on: ubuntu-latest
|
|
567
|
+
steps:
|
|
568
|
+
- uses: actions/checkout@${CHECKOUT_REF}
|
|
569
|
+
with:
|
|
570
|
+
fetch-depth: 0
|
|
571
|
+
|
|
572
|
+
- name: A dependency PR must not touch tests
|
|
573
|
+
run: |
|
|
574
|
+
changed="\$(git diff --name-only "origin/\${{ github.base_ref }}...HEAD" \
|
|
575
|
+
| grep -E '(\.(test|spec)\.[jt]sx?|/__tests__/|/e2e/|Tests?/.*\.(swift|kt))' || true)"
|
|
576
|
+
if [ -n "\$changed" ]; then
|
|
577
|
+
echo "::error::this dependency PR changes tests:"
|
|
578
|
+
echo "\$changed"
|
|
579
|
+
echo "Fix our code to match the new behaviour, or hold the upgrade."
|
|
580
|
+
exit 1
|
|
581
|
+
fi
|
|
582
|
+
echo "no test files touched"
|
|
583
|
+
|
|
584
|
+
automerge:
|
|
585
|
+
if: github.actor == 'dependabot[bot]'
|
|
586
|
+
needs: no-loosened-tests
|
|
587
|
+
runs-on: ubuntu-latest
|
|
588
|
+
steps:
|
|
589
|
+
- id: meta
|
|
590
|
+
uses: dependabot/fetch-metadata@${METADATA_REF}
|
|
591
|
+
|
|
592
|
+
# A major version is the maintainer stating the contract changed, and
|
|
593
|
+
# some of what changed — default timeouts, retry behaviour, performance
|
|
594
|
+
# — is invisible to every test. Green means "nothing we assert broke".
|
|
595
|
+
- name: Flag a major for a human
|
|
596
|
+
if: steps.meta.outputs.update-type == 'version-update:semver-major'
|
|
597
|
+
run: |
|
|
598
|
+
gh pr edit "\$PR" --add-label "needs-human"
|
|
599
|
+
gh pr comment "\$PR" --body "Major version bump — a human reads the changelog before this merges. Green CI means nothing we assert broke, not that nothing broke."
|
|
600
|
+
env:
|
|
601
|
+
PR: \${{ github.event.pull_request.html_url }}
|
|
602
|
+
GH_TOKEN: \${{ secrets.GITHUB_TOKEN }}
|
|
603
|
+
|
|
604
|
+
# --auto, so it lands only once every required check has passed.
|
|
605
|
+
- name: Auto-merge minor and patch
|
|
606
|
+
if: |
|
|
607
|
+
$([ "$AUTOMERGE" = "minor-and-patch" ] && echo true || echo false) &&
|
|
608
|
+
(steps.meta.outputs.update-type == 'version-update:semver-minor' ||
|
|
609
|
+
steps.meta.outputs.update-type == 'version-update:semver-patch')
|
|
610
|
+
run: gh pr merge --auto --squash "\$PR"
|
|
611
|
+
env:
|
|
612
|
+
PR: \${{ github.event.pull_request.html_url }}
|
|
613
|
+
GH_TOKEN: \${{ secrets.GITHUB_TOKEN }}
|
|
614
|
+
YML
|
|
615
|
+
log "dependabot policy (automerge: ${AUTOMERGE}, tests protected)"
|
|
616
|
+
[ "$AUTOMERGE" = "none" ] || warn "auto-merge needs 'Allow auto-merge' enabled in repo settings"
|
|
617
|
+
|
|
618
|
+
# ── Deploy ────────────────────────────────────────────────────────────────
|
|
619
|
+
# Generated for any app that carries a railway.json.
|
|
620
|
+
#
|
|
621
|
+
# The honest shape of safety on Railway: there is no percentage canary. What
|
|
622
|
+
# protects a release is that the previous deployment keeps serving until the
|
|
623
|
+
# new one answers its healthcheck, plus a smoke test against the real URL
|
|
624
|
+
# afterwards, plus putting the last good commit back quickly when it fails.
|
|
625
|
+
#
|
|
626
|
+
# Confidence comes from being able to undo, not from being sure.
|
|
627
|
+
RAILWAY_APPS=()
|
|
628
|
+
for a in "${APPS[@]}"; do
|
|
629
|
+
[ -f "$a/railway.json" ] && RAILWAY_APPS+=("$a")
|
|
630
|
+
done
|
|
631
|
+
|
|
632
|
+
if [ ${#RAILWAY_APPS[@]} -gt 0 ]; then
|
|
633
|
+
DEPLOY_APP="${RAILWAY_APPS[0]}"
|
|
634
|
+
write_smoke_script
|
|
635
|
+
cat > .github/workflows/deploy.yml <<YML
|
|
636
|
+
name: deploy
|
|
637
|
+
|
|
638
|
+
# Only after CI has gone green on main. Deploying on push instead would ship
|
|
639
|
+
# the commit that broke the suite, which is the one moment it matters most.
|
|
640
|
+
on:
|
|
641
|
+
workflow_run:
|
|
642
|
+
workflows: [ci]
|
|
643
|
+
types: [completed]
|
|
644
|
+
branches: [main]
|
|
645
|
+
|
|
646
|
+
concurrency:
|
|
647
|
+
group: deploy
|
|
648
|
+
cancel-in-progress: false
|
|
649
|
+
|
|
650
|
+
jobs:
|
|
651
|
+
deploy:
|
|
652
|
+
if: \${{ github.event.workflow_run.conclusion == 'success' }}
|
|
653
|
+
runs-on: ubuntu-latest
|
|
654
|
+
environment: production
|
|
655
|
+
permissions:
|
|
656
|
+
contents: write
|
|
657
|
+
steps:
|
|
658
|
+
- uses: actions/checkout@${CHECKOUT_REF}
|
|
659
|
+
with:
|
|
660
|
+
ref: \${{ github.event.workflow_run.head_sha }}
|
|
661
|
+
fetch-depth: 0
|
|
662
|
+
|
|
663
|
+
# What to put back if this release turns out bad. Read before
|
|
664
|
+
# deploying, because afterwards the tag has already moved.
|
|
665
|
+
- name: Remember the last good deploy
|
|
666
|
+
id: last
|
|
667
|
+
run: |
|
|
668
|
+
git fetch --tags --force
|
|
669
|
+
prev="\$(git rev-parse -q --verify refs/tags/last-good-deploy || true)"
|
|
670
|
+
echo "sha=\$prev" >> "\$GITHUB_OUTPUT"
|
|
671
|
+
echo "previous good deploy: \${prev:-none yet}"
|
|
672
|
+
|
|
673
|
+
- run: npm i -g @railway/cli
|
|
674
|
+
|
|
675
|
+
- name: Deploy
|
|
676
|
+
working-directory: ${DEPLOY_APP}
|
|
677
|
+
run: railway up --service "\$RAILWAY_SERVICE" --ci
|
|
678
|
+
env:
|
|
679
|
+
RAILWAY_TOKEN: \${{ secrets.RAILWAY_TOKEN }}
|
|
680
|
+
RAILWAY_SERVICE: \${{ vars.RAILWAY_SERVICE }}
|
|
681
|
+
|
|
682
|
+
# Railway's healthcheck already gates promotion on /ready. This catches
|
|
683
|
+
# what a readiness probe cannot: a route answering 500, an asset that
|
|
684
|
+
# 404s, a migration that half-applied.
|
|
685
|
+
- name: Smoke the deployed URL
|
|
686
|
+
run: node scripts/smoke.mjs "\$DEPLOY_URL"
|
|
687
|
+
env:
|
|
688
|
+
DEPLOY_URL: \${{ vars.DEPLOY_URL }}
|
|
689
|
+
|
|
690
|
+
# Railway has no rollback command, so putting the previous commit back
|
|
691
|
+
# is the rollback. Same path as a normal deploy on purpose — an
|
|
692
|
+
# untested recovery path is not a recovery path.
|
|
693
|
+
- name: Roll back
|
|
694
|
+
if: failure() && steps.last.outputs.sha != ''
|
|
695
|
+
run: |
|
|
696
|
+
echo "::error::deploy or smoke failed — restoring \${{ steps.last.outputs.sha }}"
|
|
697
|
+
git checkout "\${{ steps.last.outputs.sha }}"
|
|
698
|
+
cd ${DEPLOY_APP} && railway up --service "\$RAILWAY_SERVICE" --ci
|
|
699
|
+
env:
|
|
700
|
+
RAILWAY_TOKEN: \${{ secrets.RAILWAY_TOKEN }}
|
|
701
|
+
RAILWAY_SERVICE: \${{ vars.RAILWAY_SERVICE }}
|
|
702
|
+
|
|
703
|
+
# Moved only after the smoke passed, so it always names a release that
|
|
704
|
+
# was observed working rather than one that merely deployed.
|
|
705
|
+
- name: Mark this deploy good
|
|
706
|
+
if: success()
|
|
707
|
+
run: |
|
|
708
|
+
git tag -f last-good-deploy "\${{ github.event.workflow_run.head_sha }}"
|
|
709
|
+
git push -f origin last-good-deploy
|
|
710
|
+
YML
|
|
711
|
+
log "deploy workflow for ${DEPLOY_APP} (railway, smoke-gated, rollback on failure)"
|
|
712
|
+
warn "deploy needs: secret RAILWAY_TOKEN, vars RAILWAY_SERVICE and DEPLOY_URL"
|
|
713
|
+
fi
|
|
714
|
+
|
|
715
|
+
# ── Review labels ─────────────────────────────────────────────────────────
|
|
716
|
+
#
|
|
717
|
+
# The reviewer and the engineer hand a pull request back and forth, and the
|
|
718
|
+
# verdict has to live somewhere GitHub lets the author write. It is not the
|
|
719
|
+
# review state: the same account opens the PR and reviews it, and GitHub
|
|
720
|
+
# refuses to let an author approve their own. A label it will allow, and
|
|
721
|
+
# everything else can read one — a filter, a board, a human scanning the list,
|
|
722
|
+
# and a run picking up after a crash.
|
|
723
|
+
#
|
|
724
|
+
# --force so a re-run updates rather than failing on "already exists". Not
|
|
725
|
+
# fatal without a remote: a repo that has not been pushed yet has nowhere to
|
|
726
|
+
# put a label, and that is not a reason to fail a scaffold.
|
|
727
|
+
if gh auth status >/dev/null 2>&1 && gh repo view >/dev/null 2>&1; then
|
|
728
|
+
gh label create review:changes-requested -c D93F0B \
|
|
729
|
+
-d "Reviewer found blocking issues" -f >/dev/null 2>&1 || true
|
|
730
|
+
gh label create review:approved -c 0E8A16 \
|
|
731
|
+
-d "Reviewer found nothing blocking" -f >/dev/null 2>&1 || true
|
|
732
|
+
log "review labels created"
|
|
733
|
+
else
|
|
734
|
+
warn "no gh remote yet — create the review labels after the first push:"
|
|
735
|
+
warn " gh label create review:changes-requested -c D93F0B -f"
|
|
736
|
+
warn " gh label create review:approved -c 0E8A16 -f"
|
|
737
|
+
fi
|
|
738
|
+
|
|
739
|
+
# ── PR template ───────────────────────────────────────────────────────────
|
|
740
|
+
cat > .github/pull_request_template.md <<'MD'
|
|
741
|
+
## What
|
|
742
|
+
|
|
743
|
+
<!-- One or two sentences. What changed, not how. -->
|
|
744
|
+
|
|
745
|
+
## Why
|
|
746
|
+
|
|
747
|
+
<!-- Link the milestone or task. What problem does this close? -->
|
|
748
|
+
|
|
749
|
+
## Design
|
|
750
|
+
|
|
751
|
+
<!-- Required for any UI change. Both columns, side by side. -->
|
|
752
|
+
|
|
753
|
+
| Expected | Implemented |
|
|
754
|
+
|---|---|
|
|
755
|
+
| <!-- designs/<screen>.png --> | <!-- designs/.captures/<phase>.png --> |
|
|
756
|
+
|
|
757
|
+
<!-- gh cannot upload an image, so both files have to be committed in this
|
|
758
|
+
branch for the cells to render. If the screen could not be captured, put
|
|
759
|
+
the reason in the cell — "not captured: no simulator in the session". An
|
|
760
|
+
empty cell reads as a pass nobody observed. -->
|
|
761
|
+
|
|
762
|
+
<!-- Any deliberate difference from the design goes here, with the reason.
|
|
763
|
+
"Close enough" is not a reason. A spacing or a colour that is close is
|
|
764
|
+
not a difference, it is a defect — see
|
|
765
|
+
.claude/rules/design-parity.orchestra.md. -->
|
|
766
|
+
|
|
767
|
+
## Verification
|
|
768
|
+
|
|
769
|
+
- [ ] typecheck, test, and lint pass locally
|
|
770
|
+
- [ ] tests cover the change, including the failure paths
|
|
771
|
+
- [ ] a11y checklist done for any new or changed component
|
|
772
|
+
- [ ] no hardcoded design values — tokens only
|
|
773
|
+
MD
|
|
774
|
+
log "pr template"
|
|
775
|
+
|
|
776
|
+
# ── Commit message enforcement ────────────────────────────────────────────
|
|
777
|
+
# Pure bash on purpose: the standalone strategy has no root package.json to
|
|
778
|
+
# hang commitlint off, and this needs to work in every strategy.
|
|
779
|
+
mkdir -p .githooks
|
|
780
|
+
cat > .githooks/commit-msg <<'HOOK'
|
|
781
|
+
#!/usr/bin/env sh
|
|
782
|
+
# Conventional commits. Matches .claude/rules/commit-format.orchestra.md.
|
|
783
|
+
msg_file="$1"
|
|
784
|
+
first_line=$(head -n1 "$msg_file")
|
|
785
|
+
|
|
786
|
+
case "$first_line" in
|
|
787
|
+
"Merge "*|"Revert "*|fixup!*|squash!*) exit 0 ;;
|
|
788
|
+
esac
|
|
789
|
+
|
|
790
|
+
# Accepts both `feat(scope)!:` (Conventional Commits spec, what release-please
|
|
791
|
+
# parses) and `feat!(scope):` (the form in commit-format.orchestra.md).
|
|
792
|
+
pattern='^(feat|fix|refactor|perf|test|chore|docs|style|ci|build|revert)!?(\([a-z0-9./-]+\))?!?: .{1,}$'
|
|
793
|
+
|
|
794
|
+
# A squash-merge takes its message from the pull request title, and a task PR
|
|
795
|
+
# is titled `#<task-id> - <subject>` so the tracker links it. Without this the
|
|
796
|
+
# repo would reject the commit its own delivery flow produces — see
|
|
797
|
+
# .claude/rules/pull-request.orchestra.md.
|
|
798
|
+
#
|
|
799
|
+
# Deliberately narrow: a task id and a subject, nothing else. It is not a way
|
|
800
|
+
# to opt an ordinary commit out of the convention.
|
|
801
|
+
squashed='^#[A-Za-z0-9_-]+ - .{1,}$'
|
|
802
|
+
|
|
803
|
+
if ! printf '%s' "$first_line" | grep -qE "$pattern" \
|
|
804
|
+
&& ! printf '%s' "$first_line" | grep -qE "$squashed"; then
|
|
805
|
+
echo "commit rejected: subject must be <type>(<scope>): <description>" >&2
|
|
806
|
+
echo " got: $first_line" >&2
|
|
807
|
+
echo " types: feat fix refactor perf test chore docs style ci build revert" >&2
|
|
808
|
+
echo " (a squashed task PR may instead be '#<task-id> - <subject>')" >&2
|
|
809
|
+
exit 1
|
|
810
|
+
fi
|
|
811
|
+
|
|
812
|
+
if [ ${#first_line} -gt 72 ]; then
|
|
813
|
+
echo "commit rejected: subject is ${#first_line} chars, max 72" >&2
|
|
814
|
+
exit 1
|
|
815
|
+
fi
|
|
816
|
+
|
|
817
|
+
if printf '%s' "$(cat "$msg_file")" | grep -qi '^Co-Authored-By:'; then
|
|
818
|
+
echo "commit rejected: no Co-Authored-By trailers" >&2
|
|
819
|
+
exit 1
|
|
820
|
+
fi
|
|
821
|
+
HOOK
|
|
822
|
+
chmod +x .githooks/commit-msg
|
|
823
|
+
|
|
824
|
+
# Where the hook goes depends on what is already installed.
|
|
825
|
+
#
|
|
826
|
+
# husky owns `.husky/_` and regenerates it on every install (its own
|
|
827
|
+
# `prepare` script), and ships a `.gitignore` inside it — so a hook written
|
|
828
|
+
# there is neither committed nor durable. `.husky/` itself is the supported
|
|
829
|
+
# place for user hooks.
|
|
830
|
+
existing_hooks="$(git config --get core.hooksPath || true)"
|
|
831
|
+
case "$existing_hooks" in
|
|
832
|
+
""|".githooks")
|
|
833
|
+
git config core.hooksPath .githooks
|
|
834
|
+
log "commit-msg hook installed (.githooks)"
|
|
835
|
+
;;
|
|
836
|
+
.husky/_|.husky)
|
|
837
|
+
mkdir -p .husky
|
|
838
|
+
cp .githooks/commit-msg .husky/commit-msg
|
|
839
|
+
chmod +x .husky/commit-msg
|
|
840
|
+
rm -f .githooks/commit-msg
|
|
841
|
+
rmdir .githooks 2>/dev/null || true
|
|
842
|
+
log "commit-msg hook installed (.husky/commit-msg, husky present)"
|
|
843
|
+
;;
|
|
844
|
+
*)
|
|
845
|
+
# Someone else's hooks directory. Add to it, never replace it, and never
|
|
846
|
+
# delete a directory this script did not create.
|
|
847
|
+
mkdir -p "$existing_hooks"
|
|
848
|
+
if [ -e "$existing_hooks/commit-msg" ]; then
|
|
849
|
+
warn "$existing_hooks/commit-msg already exists — left untouched, commit format NOT enforced"
|
|
850
|
+
else
|
|
851
|
+
cp .githooks/commit-msg "$existing_hooks/commit-msg"
|
|
852
|
+
chmod +x "$existing_hooks/commit-msg"
|
|
853
|
+
log "commit-msg hook added to existing $existing_hooks"
|
|
854
|
+
fi
|
|
855
|
+
rm -f .githooks/commit-msg
|
|
856
|
+
rmdir .githooks 2>/dev/null || true
|
|
857
|
+
;;
|
|
858
|
+
esac
|
|
859
|
+
|
|
860
|
+
# Yarn 4 pins its own release under .yarn/releases, which must be committed or
|
|
861
|
+
# CI has no yarn binary. Everything else under .yarn is a cache.
|
|
862
|
+
if ! grep -q '^\.yarn/' .gitignore 2>/dev/null; then
|
|
863
|
+
cat >> .gitignore <<'YG'
|
|
864
|
+
|
|
865
|
+
# yarn — keep the pinned release, ignore the caches
|
|
866
|
+
.yarn/*
|
|
867
|
+
!.yarn/releases
|
|
868
|
+
.pnp.*
|
|
869
|
+
YG
|
|
870
|
+
log "gitignore: yarn caches ignored, pinned release kept"
|
|
871
|
+
fi
|
|
872
|
+
|
|
873
|
+
# node_modules at the repo root. Each create tool ignores it inside its own
|
|
874
|
+
# app directory, which is enough for `single` and `standalone` — but a yarn
|
|
875
|
+
# workspace hoists the whole tree to the root, where nothing covered it, and
|
|
876
|
+
# the first `git add -A` stages sixty thousand files.
|
|
877
|
+
if ! grep -q '^node_modules/' .gitignore 2>/dev/null; then
|
|
878
|
+
cat >> .gitignore <<'NMG'
|
|
879
|
+
|
|
880
|
+
# dependencies — hoisted here by a workspace, per-app otherwise
|
|
881
|
+
node_modules/
|
|
882
|
+
NMG
|
|
883
|
+
log "gitignore: node_modules ignored"
|
|
884
|
+
fi
|
|
885
|
+
|
|
886
|
+
# `.env` is gitignored in every project, always — the security rule says so
|
|
887
|
+
# and, until now, nothing did it. A create tool ignores .env inside its own
|
|
888
|
+
# app directory; nothing covered the repo root or a hand-made app.
|
|
889
|
+
#
|
|
890
|
+
# .env.example is the exception, and it is committed on purpose: it is the
|
|
891
|
+
# only record of what a new machine needs in order to boot.
|
|
892
|
+
if ! grep -q '^\.env$' .gitignore 2>/dev/null; then
|
|
893
|
+
cat >> .gitignore <<'ENVG'
|
|
894
|
+
|
|
895
|
+
# secrets never get committed; the example of what is needed always does
|
|
896
|
+
.env
|
|
897
|
+
.env.*
|
|
898
|
+
!.env.example
|
|
899
|
+
ENVG
|
|
900
|
+
log "gitignore: .env ignored, .env.example kept"
|
|
901
|
+
fi
|
|
902
|
+
|
|
903
|
+
cat > .editorconfig <<'EC'
|
|
904
|
+
root = true
|
|
905
|
+
|
|
906
|
+
[*]
|
|
907
|
+
charset = utf-8
|
|
908
|
+
end_of_line = lf
|
|
909
|
+
insert_final_newline = true
|
|
910
|
+
trim_trailing_whitespace = true
|
|
911
|
+
indent_style = space
|
|
912
|
+
indent_size = 2
|
|
913
|
+
|
|
914
|
+
[*.{kt,kts,swift}]
|
|
915
|
+
indent_size = 4
|
|
916
|
+
|
|
917
|
+
[*.md]
|
|
918
|
+
trim_trailing_whitespace = false
|
|
919
|
+
EC
|
|
920
|
+
log "editorconfig"
|
|
921
|
+
|
|
922
|
+
|
|
923
|
+
# ── Workspace (Turborepo) ─────────────────────────────────────────────────
|
|
924
|
+
# One lockfile at the root, turbo for task orchestration and caching. The
|
|
925
|
+
# per-app yarn setup each stack performs is undone here — a workspace has a
|
|
926
|
+
# single resolution root, and leaving per-app lockfiles behind produces two
|
|
927
|
+
# competing dependency trees.
|
|
928
|
+
if [ "$MODE" = "workspace" ]; then
|
|
929
|
+
for a in "${APPS[@]}"; do
|
|
930
|
+
rm -f "$a/yarn.lock" "$a/.yarnrc.yml"
|
|
931
|
+
rm -rf "$a/.yarn"
|
|
932
|
+
done
|
|
933
|
+
|
|
934
|
+
printf 'nodeLinker: node-modules\n' > .yarnrc.yml
|
|
935
|
+
|
|
936
|
+
node -e '
|
|
937
|
+
const fs = require("fs");
|
|
938
|
+
const apps = process.argv.slice(1);
|
|
939
|
+
const pkg = {
|
|
940
|
+
name: "workspace-root",
|
|
941
|
+
private: true,
|
|
942
|
+
workspaces: apps,
|
|
943
|
+
scripts: {
|
|
944
|
+
dev: "turbo run dev",
|
|
945
|
+
build: "turbo run build",
|
|
946
|
+
test: "turbo run test",
|
|
947
|
+
lint: "turbo run lint",
|
|
948
|
+
typecheck: "turbo run typecheck",
|
|
949
|
+
"test:coverage": "turbo run test:coverage",
|
|
950
|
+
verify: "turbo run typecheck test lint test:coverage",
|
|
951
|
+
},
|
|
952
|
+
};
|
|
953
|
+
fs.writeFileSync("package.json", JSON.stringify(pkg, null, 2) + "\n");
|
|
954
|
+
' "${APPS[@]}"
|
|
955
|
+
|
|
956
|
+
cat > turbo.json <<'TURBO'
|
|
957
|
+
{
|
|
958
|
+
"$schema": "https://turbo.build/schema.json",
|
|
959
|
+
"tasks": {
|
|
960
|
+
"build": { "dependsOn": ["^build"], "outputs": ["dist/**", ".next/**", "!.next/cache/**"] },
|
|
961
|
+
"typecheck": { "dependsOn": ["^build"] },
|
|
962
|
+
"lint": {},
|
|
963
|
+
"test": { "dependsOn": ["^build"] },
|
|
964
|
+
"test:coverage": { "dependsOn": ["^build"], "outputs": ["coverage/**"] },
|
|
965
|
+
"dev": { "cache": false, "persistent": true }
|
|
966
|
+
}
|
|
967
|
+
}
|
|
968
|
+
TURBO
|
|
969
|
+
|
|
970
|
+
# turbo writes a cache and a per-task log into .turbo/, at the root and
|
|
971
|
+
# inside every package. Nothing ignored it, so the first commit carried
|
|
972
|
+
# turbo-lint.log and a tar.zst cache blob, and every run after it churned
|
|
973
|
+
# them.
|
|
974
|
+
if ! grep -q '^\.turbo/' .gitignore 2>/dev/null; then
|
|
975
|
+
printf '\n# turbo cache and task logs, root and per-package\n.turbo/\n' >> .gitignore
|
|
976
|
+
log "gitignore: .turbo ignored"
|
|
977
|
+
fi
|
|
978
|
+
|
|
979
|
+
corepack enable >/dev/null 2>&1 || true
|
|
980
|
+
yarn set version stable >/dev/null 2>&1 || warn "could not pin yarn at the root"
|
|
981
|
+
yarn add -D turbo@latest >/dev/null 2>&1 || warn "turbo install failed — run 'yarn add -D turbo' at the root"
|
|
982
|
+
log "workspace wired: yarn workspaces + turborepo (${#APPS[@]} apps)"
|
|
983
|
+
fi
|
|
984
|
+
|
|
985
|
+
# ── Standalone task runner ────────────────────────────────────────────────
|
|
986
|
+
# The standalone strategy has no root package.json on purpose, so this
|
|
987
|
+
# script takes the place of `npm run`.
|
|
988
|
+
if [ "$STANDALONE" -eq 1 ]; then
|
|
989
|
+
{
|
|
990
|
+
cat <<'RUNNER'
|
|
991
|
+
#!/usr/bin/env bash
|
|
992
|
+
# Root task runner. No root package.json by design — each app owns its own.
|
|
993
|
+
#
|
|
994
|
+
# ./run.sh install install every app
|
|
995
|
+
# ./run.sh dev <app> start one app
|
|
996
|
+
# ./run.sh test [app] test one app, or all
|
|
997
|
+
# ./run.sh lint [app]
|
|
998
|
+
# ./run.sh typecheck [app]
|
|
999
|
+
# ./run.sh build [app]
|
|
1000
|
+
# ./run.sh verify [app] typecheck + test + lint + coverage
|
|
1001
|
+
set -euo pipefail
|
|
1002
|
+
|
|
1003
|
+
RUNNER
|
|
1004
|
+
printf 'APPS=('
|
|
1005
|
+
for a in "${APPS[@]}"; do printf '"%s" ' "$a"; done
|
|
1006
|
+
printf ')\n'
|
|
1007
|
+
cat <<'RUNNER'
|
|
1008
|
+
|
|
1009
|
+
cmd="${1:-}"; shift || true
|
|
1010
|
+
target="${1:-}"
|
|
1011
|
+
|
|
1012
|
+
# Apps are not all npm. Native mobile forces this strategy, so dispatch on
|
|
1013
|
+
# what is actually in the directory. verify.sh is the normalised gate a
|
|
1014
|
+
# non-npm app exposes.
|
|
1015
|
+
__run() { # task, run from inside the app dir
|
|
1016
|
+
local task="$1"
|
|
1017
|
+
if [ -f verify.sh ]; then
|
|
1018
|
+
case "$task" in
|
|
1019
|
+
install|dev|build) echo "(skipped: $task not defined for a verify.sh app)" ;;
|
|
1020
|
+
*) ./verify.sh ;;
|
|
1021
|
+
esac
|
|
1022
|
+
elif [ -f package.json ]; then
|
|
1023
|
+
case "$task" in
|
|
1024
|
+
install) yarn install ;;
|
|
1025
|
+
*) yarn "$task" ;;
|
|
1026
|
+
esac
|
|
1027
|
+
else
|
|
1028
|
+
echo "no package.json and no verify.sh — cannot $task" >&2
|
|
1029
|
+
return 1
|
|
1030
|
+
fi
|
|
1031
|
+
}
|
|
1032
|
+
|
|
1033
|
+
run_in() { # dir, __run, task
|
|
1034
|
+
local dir="$1"; shift
|
|
1035
|
+
[ -d "$dir" ] || { echo "no such app: $dir" >&2; exit 1; }
|
|
1036
|
+
echo "──> $dir: $*"
|
|
1037
|
+
( cd "$dir" && "$@" )
|
|
1038
|
+
}
|
|
1039
|
+
|
|
1040
|
+
each() { # script...
|
|
1041
|
+
local failed=()
|
|
1042
|
+
for a in "${APPS[@]}"; do
|
|
1043
|
+
run_in "$a" "$@" || failed+=("$a")
|
|
1044
|
+
done
|
|
1045
|
+
[ ${#failed[@]} -eq 0 ] || { echo "failed: ${failed[*]}" >&2; exit 1; }
|
|
1046
|
+
}
|
|
1047
|
+
|
|
1048
|
+
# `a && b || c` runs c when b FAILS, not only when a is false — which would
|
|
1049
|
+
# run every app's suite because one app's suite went red. Always if/else.
|
|
1050
|
+
one_or_all() { # script...
|
|
1051
|
+
if [ -n "$target" ]; then run_in "$target" "$@"; else each "$@"; fi
|
|
1052
|
+
}
|
|
1053
|
+
|
|
1054
|
+
case "$cmd" in
|
|
1055
|
+
install) each __run install ;;
|
|
1056
|
+
dev) [ -n "$target" ] || { echo "usage: ./run.sh dev <app>" >&2; exit 1; }
|
|
1057
|
+
run_in "$target" __run dev ;;
|
|
1058
|
+
test) one_or_all __run test ;;
|
|
1059
|
+
lint) one_or_all __run lint ;;
|
|
1060
|
+
typecheck) one_or_all __run typecheck ;;
|
|
1061
|
+
build) one_or_all __run build ;;
|
|
1062
|
+
verify)
|
|
1063
|
+
for s in typecheck test lint test:coverage; do
|
|
1064
|
+
one_or_all __run "$s"
|
|
1065
|
+
done ;;
|
|
1066
|
+
*)
|
|
1067
|
+
sed -n '2,10p' "$0" | sed 's/^# \{0,1\}//'
|
|
1068
|
+
exit 1 ;;
|
|
1069
|
+
esac
|
|
1070
|
+
RUNNER
|
|
1071
|
+
} > run.sh
|
|
1072
|
+
chmod +x run.sh
|
|
1073
|
+
log "root run.sh task runner (${#APPS[@]} apps)"
|
|
1074
|
+
fi
|
|
1075
|
+
|
|
1076
|
+
# Where a design lives, from the first commit rather than from the first
|
|
1077
|
+
# argument about it. A phase's `design:` field points in here, lead fetches a
|
|
1078
|
+
# URL design into .fetched/, and the capture the PR shows lands in .captures/.
|
|
1079
|
+
# Committed, both of them: gh cannot upload an image, so a screenshot that is
|
|
1080
|
+
# not in the branch cannot appear in a pull request.
|
|
1081
|
+
mkdir -p designs/.captures designs/.fetched
|
|
1082
|
+
# git does not track an empty directory, so without these the two folders
|
|
1083
|
+
# vanish on the next clone and the first phase with a design has to invent a
|
|
1084
|
+
# location — which is how two of them end up disagreeing.
|
|
1085
|
+
: > designs/.captures/.gitkeep
|
|
1086
|
+
: > designs/.fetched/.gitkeep
|
|
1087
|
+
cat > designs/README.md <<'MD'
|
|
1088
|
+
# designs
|
|
1089
|
+
|
|
1090
|
+
The source of truth for any screen this repo builds.
|
|
1091
|
+
|
|
1092
|
+
```
|
|
1093
|
+
designs/
|
|
1094
|
+
├── <screen>.png the design. mirrors the screen structure.
|
|
1095
|
+
├── .fetched/ a design given as a URL, fetched once when the phase starts
|
|
1096
|
+
└── .captures/ what was actually built, so a pull request can show both
|
|
1097
|
+
```
|
|
1098
|
+
|
|
1099
|
+
A phase points at one with `design:` in its frontmatter — a path in here, or a
|
|
1100
|
+
URL. No field means no design, and nothing may infer one from a phase title: a
|
|
1101
|
+
check against a guessed design reports a pass nobody earned.
|
|
1102
|
+
|
|
1103
|
+
What counts as matching, and what blocks, is in
|
|
1104
|
+
`.claude/rules/design-parity.orchestra.md`.
|
|
1105
|
+
MD
|
|
1106
|
+
log "designs/ (source, fetched, captures) — a design has a home before it is needed"
|
|
1107
|
+
|
|
1108
|
+
log "repo config done — CI, dependabot, PR template, commit hook"
|
|
1109
|
+
warn "branch protection cannot be set from here. Require the CI check on main in repo settings."
|