@crossdelta/platform-sdk 0.26.10 → 0.26.12
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/CHANGELOG.md +35 -0
- package/bin/templates/workspace/.github/actions/check-image-tag-exists/ghcr.js +47 -13
- package/bin/templates/workspace/.github/actions/check-image-tag-exists/index.js +6 -5
- package/bin/templates/workspace/.github/actions/generate-scope-matrix/action.yml +7 -0
- package/bin/templates/workspace/.github/actions/generate-scope-matrix/index.js +38 -16
- package/bin/templates/workspace/.github/actions/generate-scope-matrix/rollout-changes.js +24 -0
- package/bin/templates/workspace/.github/actions/generate-scope-matrix/scope-tree.js +14 -8
- package/bin/templates/workspace/.github/actions/generate-scope-matrix/select.js +10 -8
- package/bin/templates/workspace/.github/actions/prepare-build-context/action.yml +3 -1
- package/bin/templates/workspace/.github/actions/setup-bun-install/action.yml.hbs +37 -3
- package/bin/templates/workspace/.github/workflows/build-and-deploy.yml.hbs +44 -19
- package/bin/templates/workspace/.github/workflows/lint-and-tests.yml.hbs +2 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,41 @@ All notable changes to `@crossdelta/platform-sdk` will be documented in this fil
|
|
|
5
5
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
|
6
6
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
7
|
|
|
8
|
+
## [0.26.12] - 2026-10-28
|
|
9
|
+
|
|
10
|
+
### Fixed
|
|
11
|
+
- **`check-image-tag-exists` finds existing tags again.** The runner sets the `repository-owner`
|
|
12
|
+
input, declared without a default, to an empty string, which skipped the fallback to
|
|
13
|
+
`$GITHUB_REPOSITORY_OWNER`; the lookup asked `/orgs//packages/…`, got 404 and reported every tag
|
|
14
|
+
as missing, so a re-run build job rebuilt an image GHCR already held. An empty owner now falls back
|
|
15
|
+
like the prefix does.
|
|
16
|
+
|
|
17
|
+
## [0.26.11] - 2026-10-28
|
|
18
|
+
|
|
19
|
+
### Changed
|
|
20
|
+
- **A manual dispatch of the generated `build-and-deploy.yml` builds only the images GHCR lacks.**
|
|
21
|
+
It used to rebuild every scope; it now asks GHCR like the publish path does, rolls out every
|
|
22
|
+
scope's tag for this commit and skips the base worktree through the new `compare-base` input of
|
|
23
|
+
`generate-scope-matrix`.
|
|
24
|
+
- **CI installs skip the root `prepare` where no job reads a built package.** `setup-bun-install`
|
|
25
|
+
has a new `ignore-scripts` input (default `false`); the generated workflows set it in
|
|
26
|
+
`prepare-scopes`, the build job, the deploy and `lint`. The deploy builds the stack's packages
|
|
27
|
+
itself; `test` keeps `prepare`, since a package's tests may read its own build output, which
|
|
28
|
+
`^build` does not produce.
|
|
29
|
+
- **Apps export the Docker layer cache with `mode=min`.** Exporting their builder layers took one to
|
|
30
|
+
two minutes per build and rarely paid off; services keep `mode=max`.
|
|
31
|
+
- **A library's `devDependencies` no longer count towards a scope's checksum.** Only the scope's own
|
|
32
|
+
`devDependencies` do, so a test dependency of a library no longer rebuilds every scope that uses
|
|
33
|
+
the library. Every scope gets a new tag once.
|
|
34
|
+
|
|
35
|
+
### Fixed
|
|
36
|
+
- **The image tag lookup logs its answer.** `check-image-tag-exists` and `generate-scope-matrix`
|
|
37
|
+
print the scope, the tag, whether the package exists and the HTTP status of every page read; an
|
|
38
|
+
API error carries its status.
|
|
39
|
+
- **`prepare-build-context` regenerates `bun.lock` without lifecycle scripts.** The install there
|
|
40
|
+
only rewrites the lockfile, and no dependency's script should run in a job that may later hold
|
|
41
|
+
deploy secrets.
|
|
42
|
+
|
|
8
43
|
## [0.26.10] - 2026-10-27
|
|
9
44
|
|
|
10
45
|
### Fixed
|
|
@@ -5,6 +5,11 @@
|
|
|
5
5
|
* generate-scope-matrix/index.js requires this file by its relative path: moving it breaks that action.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
+
/**
|
|
9
|
+
* @typedef {{exists: boolean, packageFound: boolean, statuses: number[]}} GhcrLookup
|
|
10
|
+
* `packageFound` is false after a 404; `statuses` holds the HTTP status of every page read.
|
|
11
|
+
*/
|
|
12
|
+
|
|
8
13
|
const PAGE_SIZE = 100
|
|
9
14
|
|
|
10
15
|
/**
|
|
@@ -17,6 +22,16 @@ const PAGE_SIZE = 100
|
|
|
17
22
|
const resolveRepositoryPrefix = (input, env) =>
|
|
18
23
|
input || env.GHCR_REPOSITORY_PREFIX || env.GITHUB_REPOSITORY?.split('/')[1] || 'platform'
|
|
19
24
|
|
|
25
|
+
/**
|
|
26
|
+
* Resolves the GHCR package owner: the explicit input, else $GITHUB_REPOSITORY_OWNER. The runner sets
|
|
27
|
+
* a declared input without a default to '', so an empty input has to fall back here; an empty owner
|
|
28
|
+
* asks `/orgs//packages/…`, which answers 404 and reads as "missing".
|
|
29
|
+
* @param {string} input - The action's repository-owner input, possibly empty
|
|
30
|
+
* @param {Record<string, string | undefined>} env - Process environment
|
|
31
|
+
* @returns {string}
|
|
32
|
+
*/
|
|
33
|
+
const resolveRepositoryOwner = (input, env) => input || env.GITHUB_REPOSITORY_OWNER || ''
|
|
34
|
+
|
|
20
35
|
/**
|
|
21
36
|
* Checks if the target tag exists in a list of container versions.
|
|
22
37
|
* @param {Object[]} versions - Array of version objects from GitHub API
|
|
@@ -29,6 +44,16 @@ const tagExistsInVersions = (versions, targetTag) =>
|
|
|
29
44
|
return Array.isArray(tags) && tags.includes(targetTag)
|
|
30
45
|
})
|
|
31
46
|
|
|
47
|
+
/**
|
|
48
|
+
* One log line for a lookup, so a run shows why the build job did or did not find a tag.
|
|
49
|
+
* @param {string} shortName - Scope short name
|
|
50
|
+
* @param {string} tag - The tag looked up
|
|
51
|
+
* @param {GhcrLookup} lookup
|
|
52
|
+
* @returns {string}
|
|
53
|
+
*/
|
|
54
|
+
const describeLookup = (shortName, tag, { exists, packageFound, statuses }) =>
|
|
55
|
+
`${shortName}:${tag} ${exists ? 'exists' : 'missing'}${packageFound ? '' : ' (package not found)'}, HTTP ${statuses.join(' ')}`
|
|
56
|
+
|
|
32
57
|
/**
|
|
33
58
|
* Creates a GHCR client for one package owner and repository prefix.
|
|
34
59
|
* @param {Object} options
|
|
@@ -37,9 +62,10 @@ const tagExistsInVersions = (versions, targetTag) =>
|
|
|
37
62
|
* @param {string} options.token - Token with read:packages
|
|
38
63
|
* @param {number} [options.maxPages=5] - Pages of 100 versions to inspect before giving up
|
|
39
64
|
* @param {typeof fetch} [options.fetchImpl=fetch] - Injected for tests
|
|
40
|
-
* @returns {{
|
|
41
|
-
* `
|
|
42
|
-
*
|
|
65
|
+
* @returns {{lookupTag: (shortName: string, tag: string) => Promise<GhcrLookup>}}
|
|
66
|
+
* `lookupTag` resolves "missing" when the package does not exist yet (404) and rejects on any other API
|
|
67
|
+
* error, with its HTTP status as `error.status`, so a caller decides itself whether an unknown
|
|
68
|
+
* answer means "build".
|
|
43
69
|
*/
|
|
44
70
|
const createGhcrClient = ({ owner, prefix, token, maxPages = 5, fetchImpl = fetch }) => {
|
|
45
71
|
const headers = {
|
|
@@ -55,33 +81,41 @@ const createGhcrClient = ({ owner, prefix, token, maxPages = 5, fetchImpl = fetc
|
|
|
55
81
|
|
|
56
82
|
// 404 means the package doesn't exist yet (first build)
|
|
57
83
|
if (response.status === 404) {
|
|
58
|
-
return { notFound: true, versions: [], hasMore: false }
|
|
84
|
+
return { notFound: true, versions: [], hasMore: false, status: response.status }
|
|
59
85
|
}
|
|
60
86
|
|
|
61
87
|
if (!response.ok) {
|
|
62
88
|
const body = await response.text()
|
|
63
|
-
throw new Error(`GitHub API error (${response.status}): ${body}`)
|
|
89
|
+
throw Object.assign(new Error(`GitHub API error (${response.status}): ${body}`), { status: response.status })
|
|
64
90
|
}
|
|
65
91
|
|
|
66
92
|
const data = await response.json()
|
|
67
93
|
const versions = Array.isArray(data) ? data : []
|
|
68
|
-
return { notFound: false, versions, hasMore: versions.length === PAGE_SIZE }
|
|
94
|
+
return { notFound: false, versions, hasMore: versions.length === PAGE_SIZE, status: response.status }
|
|
69
95
|
}
|
|
70
96
|
|
|
71
|
-
const
|
|
97
|
+
const lookupTag = async (shortName, tag) => {
|
|
72
98
|
const targetTag = tag.trim()
|
|
99
|
+
const statuses = []
|
|
73
100
|
|
|
74
101
|
for (let page = 1; page <= maxPages; page += 1) {
|
|
75
|
-
const { versions, hasMore, notFound } = await fetchVersions(shortName, page)
|
|
76
|
-
|
|
77
|
-
if (
|
|
102
|
+
const { versions, hasMore, notFound, status } = await fetchVersions(shortName, page)
|
|
103
|
+
statuses.push(status)
|
|
104
|
+
if (notFound) return { exists: false, packageFound: false, statuses }
|
|
105
|
+
if (tagExistsInVersions(versions, targetTag)) return { exists: true, packageFound: true, statuses }
|
|
78
106
|
if (!hasMore) break
|
|
79
107
|
}
|
|
80
108
|
|
|
81
|
-
return false
|
|
109
|
+
return { exists: false, packageFound: true, statuses }
|
|
82
110
|
}
|
|
83
111
|
|
|
84
|
-
return {
|
|
112
|
+
return { lookupTag }
|
|
85
113
|
}
|
|
86
114
|
|
|
87
|
-
module.exports = {
|
|
115
|
+
module.exports = {
|
|
116
|
+
createGhcrClient,
|
|
117
|
+
describeLookup,
|
|
118
|
+
resolveRepositoryOwner,
|
|
119
|
+
resolveRepositoryPrefix,
|
|
120
|
+
tagExistsInVersions,
|
|
121
|
+
}
|
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
|
|
17
17
|
const { appendFileSync } = require('node:fs')
|
|
18
18
|
const { exit } = require('node:process')
|
|
19
|
-
const { createGhcrClient, resolveRepositoryPrefix } = require('./ghcr.js')
|
|
19
|
+
const { createGhcrClient, describeLookup, resolveRepositoryOwner, resolveRepositoryPrefix } = require('./ghcr.js')
|
|
20
20
|
|
|
21
21
|
/**
|
|
22
22
|
* Builds environment variable keys for GitHub Actions inputs.
|
|
@@ -71,7 +71,7 @@ const imageTag = getInput('image-tag', { required: true })
|
|
|
71
71
|
const githubToken = getInput('github-token', { required: true })
|
|
72
72
|
|
|
73
73
|
// Optional inputs - defaults from environment for portability
|
|
74
|
-
const repositoryOwner = getInput('repository-owner',
|
|
74
|
+
const repositoryOwner = resolveRepositoryOwner(getInput('repository-owner'), process.env)
|
|
75
75
|
const repositoryPrefix = resolveRepositoryPrefix(getInput('repository-prefix'), process.env)
|
|
76
76
|
const maxPages = Number(getInput('max-pages', { defaultValue: '5' }))
|
|
77
77
|
|
|
@@ -82,10 +82,11 @@ const ghcr = createGhcrClient({ owner: repositoryOwner, prefix: repositoryPrefix
|
|
|
82
82
|
*/
|
|
83
83
|
const run = async () => {
|
|
84
84
|
try {
|
|
85
|
-
const
|
|
86
|
-
|
|
85
|
+
const lookup = await ghcr.lookupTag(scopeShortName, imageTag)
|
|
86
|
+
console.log(`${describeLookup(scopeShortName, imageTag, lookup)} (${repositoryOwner}/${repositoryPrefix})`)
|
|
87
|
+
setOutput('exists', String(lookup.exists))
|
|
87
88
|
} catch (error) {
|
|
88
|
-
console.error(
|
|
89
|
+
console.error(`Failed to check image tag existence (HTTP ${error.status ?? 'none'}):`, error)
|
|
89
90
|
setOutput('exists', 'false')
|
|
90
91
|
exit(1)
|
|
91
92
|
}
|
|
@@ -18,6 +18,13 @@ inputs:
|
|
|
18
18
|
`github-token`. Ignored when force-all is true.
|
|
19
19
|
required: false
|
|
20
20
|
default: 'false'
|
|
21
|
+
compare-base:
|
|
22
|
+
description: >-
|
|
23
|
+
With only-unbuilt: when true, also compute every scope's tag at GITHUB_EVENT_BEFORE in a
|
|
24
|
+
temporary worktree and report `tags_changed`. False skips that worktree (a manual dispatch
|
|
25
|
+
deploys anyway); `tags_changed` is then 'false'.
|
|
26
|
+
required: false
|
|
27
|
+
default: 'true'
|
|
21
28
|
github-token:
|
|
22
29
|
description: Token with read:packages, used by only-unbuilt to query GHCR
|
|
23
30
|
required: false
|
|
@@ -14,7 +14,8 @@
|
|
|
14
14
|
* scope-roots: apps,services
|
|
15
15
|
*
|
|
16
16
|
* With `only-unbuilt`, a scope is in the matrix when GHCR lacks its checksum tag instead of when
|
|
17
|
-
* its directory changed, and each entry carries that tag as `imageTag`.
|
|
17
|
+
* its directory changed, and each entry carries that tag as `imageTag`. `compare-base: false`
|
|
18
|
+
* skips the tag comparison with the diff base.
|
|
18
19
|
*
|
|
19
20
|
* @outputs scopes - JSON array of scope objects [{name, shortName, dir, run, imageTag?}]
|
|
20
21
|
* @outputs scopes_count - Number of scopes in the matrix
|
|
@@ -25,8 +26,8 @@ const { appendFileSync } = require('node:fs')
|
|
|
25
26
|
const { spawnSync } = require('node:child_process')
|
|
26
27
|
const { existsSync, readdirSync, readFileSync, statSync, symlinkSync } = require('node:fs')
|
|
27
28
|
const { basename, join, relative } = require('node:path')
|
|
28
|
-
const { createGhcrClient, resolveRepositoryPrefix } = require('../check-image-tag-exists/ghcr.js')
|
|
29
|
-
const {
|
|
29
|
+
const { createGhcrClient, describeLookup, resolveRepositoryPrefix } = require('../check-image-tag-exists/ghcr.js')
|
|
30
|
+
const { changedScopes, scopeTagMap, selectUnbuilt } = require('./select.js')
|
|
30
31
|
|
|
31
32
|
/** Default directories to scan for scopes (Turborepo convention) */
|
|
32
33
|
const defaultRoots = ['apps', 'services']
|
|
@@ -379,11 +380,16 @@ const lookupScopeTags = async (scopeMap) => {
|
|
|
379
380
|
|
|
380
381
|
for (const scope of scopeMap.values()) {
|
|
381
382
|
const imageTag = computeScopeChecksum(scope)
|
|
382
|
-
const exists = await ghcr
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
383
|
+
const exists = await ghcr
|
|
384
|
+
.lookupTag(scope.shortName, imageTag)
|
|
385
|
+
.then((lookup) => {
|
|
386
|
+
console.log(describeLookup(scope.shortName, imageTag, lookup))
|
|
387
|
+
return lookup.exists
|
|
388
|
+
})
|
|
389
|
+
.catch((error) => {
|
|
390
|
+
console.warn(`GHCR lookup for ${scope.shortName} failed (HTTP ${error.status ?? 'none'}), building it:`, error)
|
|
391
|
+
return false
|
|
392
|
+
})
|
|
387
393
|
lookups.push({ scope, imageTag, exists })
|
|
388
394
|
}
|
|
389
395
|
|
|
@@ -496,8 +502,29 @@ const buildMatrix = (scopeMap, changedFilesFromEvent, beforeRef, headRef, forced
|
|
|
496
502
|
}
|
|
497
503
|
|
|
498
504
|
/**
|
|
499
|
-
*
|
|
500
|
-
*
|
|
505
|
+
* The only-unbuilt matrix: every scope whose checksum tag GHCR lacks, every scope's computed tag,
|
|
506
|
+
* and, unless compare-base is off, the scopes whose tag differs from the base's.
|
|
507
|
+
* @param {Map} scopeMap - Discovered scopes
|
|
508
|
+
* @param {string[]} forcedShortNames - Short names included even when their image exists
|
|
509
|
+
* @param {string|undefined} beforeRef - Diff base commit
|
|
510
|
+
* @returns {Promise<{matrix: Object[], scopeTags: Record<string, string>, changedTags: string[]}>}
|
|
511
|
+
*/
|
|
512
|
+
const resolveUnbuiltMatrix = async (scopeMap, forcedShortNames, beforeRef) => {
|
|
513
|
+
const lookups = await lookupScopeTags(scopeMap)
|
|
514
|
+
const scopeTags = scopeTagMap(lookups)
|
|
515
|
+
const matrix = selectUnbuilt(lookups, forcedShortNames)
|
|
516
|
+
if (!isEnabled('compare-base')) {
|
|
517
|
+
console.log('Tags not compared with a base (compare-base is off)')
|
|
518
|
+
return { matrix, scopeTags, changedTags: [] }
|
|
519
|
+
}
|
|
520
|
+
const baseScopeTags = beforeRef ? computeBaseScopeTags(scopeMap, beforeRef) : {}
|
|
521
|
+
const changedTags = changedScopes(scopeTags, baseScopeTags)
|
|
522
|
+
console.log(`Tags changed since ${beforeRef ?? 'no base'}: ${changedTags.join(' ') || 'none'}`)
|
|
523
|
+
return { matrix, scopeTags, changedTags }
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
/**
|
|
527
|
+
* Builds the matrix: with only-unbuilt, see resolveUnbuiltMatrix; otherwise the changed scopes (or
|
|
501
528
|
* all with force-all), no tags and no tag changes.
|
|
502
529
|
* @returns {Promise<{matrix: Object[], scopeTags: Record<string, string>, changedTags: string[]}>}
|
|
503
530
|
*/
|
|
@@ -507,12 +534,7 @@ const resolveMatrix = async () => {
|
|
|
507
534
|
const { beforeRef, headRef } = getCommitRefs()
|
|
508
535
|
|
|
509
536
|
if (isEnabled('only-unbuilt') && !isForceAll()) {
|
|
510
|
-
|
|
511
|
-
const scopeTags = scopeTagMap(lookups)
|
|
512
|
-
const baseScopeTags = beforeRef ? computeBaseScopeTags(scopeMap, beforeRef) : {}
|
|
513
|
-
const changedTags = changedScopeTags(scopeTags, baseScopeTags)
|
|
514
|
-
console.log(`Tags changed since ${beforeRef ?? 'no base'}: ${changedTags.join(' ') || 'none'}`)
|
|
515
|
-
return { matrix: selectUnbuilt(lookups, forcedShortNames), scopeTags, changedTags }
|
|
537
|
+
return resolveUnbuiltMatrix(scopeMap, forcedShortNames, beforeRef)
|
|
516
538
|
}
|
|
517
539
|
|
|
518
540
|
const changedFilesFromEvent = loadEventChangedFiles(process.env.GITHUB_EVENT_PATH)
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Usage: DEPLOY_IMAGES='<json>' RUNNING_IMAGES='<json>' node rollout-changes.js
|
|
3
|
+
*
|
|
4
|
+
* Prints, as a JSON array, the short names of the scopes the deploy rolls out to an image other than
|
|
5
|
+
* the one the stack runs. Both variables map short name → image; a scope the stack reports no image
|
|
6
|
+
* for counts as changed, so an empty RUNNING_IMAGES ('{}') lists every scope of DEPLOY_IMAGES.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
const { changedScopes } = require('./select.js')
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Parses an environment variable holding a JSON object of short name → image.
|
|
13
|
+
* @param {string} name - Variable name
|
|
14
|
+
* @returns {Record<string, string>}
|
|
15
|
+
*/
|
|
16
|
+
const readImages = (name) => {
|
|
17
|
+
const value = JSON.parse(process.env[name] || '{}')
|
|
18
|
+
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
|
|
19
|
+
throw new Error(`${name} is not a JSON object`)
|
|
20
|
+
}
|
|
21
|
+
return value
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
console.log(JSON.stringify(changedScopes(readImages('DEPLOY_IMAGES'), readImages('RUNNING_IMAGES'))))
|
|
@@ -9,27 +9,32 @@
|
|
|
9
9
|
const { createHash } = require('node:crypto')
|
|
10
10
|
|
|
11
11
|
/**
|
|
12
|
-
* @typedef {{name: string, dependencies: string[]}} WorkspaceManifest
|
|
12
|
+
* @typedef {{name: string, dependencies: string[], devDependencies: string[]}} WorkspaceManifest
|
|
13
13
|
*/
|
|
14
14
|
|
|
15
|
-
const
|
|
15
|
+
const CONSUMER_DEPENDENCY_FIELDS = ['dependencies', 'peerDependencies', 'optionalDependencies']
|
|
16
16
|
const PRUNED_TREES = ['json', 'full']
|
|
17
17
|
const WORKSPACE_BLOCK_INDENT = ' '
|
|
18
18
|
|
|
19
19
|
/**
|
|
20
|
-
* Reduces a parsed package.json to its name and the names of
|
|
21
|
-
*
|
|
22
|
-
* instead of `workspace:` stays in
|
|
20
|
+
* Reduces a parsed package.json to its name and the names of what it depends on: `dependencies`
|
|
21
|
+
* holds every field a consumer installs, `devDependencies` the package's own tooling and tests. The
|
|
22
|
+
* version spec does not matter, so a workspace linked by version instead of `workspace:` stays in
|
|
23
|
+
* the closure.
|
|
23
24
|
* @param {Object} packageJson - Parsed package.json
|
|
24
25
|
* @returns {WorkspaceManifest}
|
|
25
26
|
*/
|
|
26
27
|
const workspaceManifest = (packageJson) => ({
|
|
27
28
|
name: packageJson.name,
|
|
28
|
-
dependencies:
|
|
29
|
+
dependencies: CONSUMER_DEPENDENCY_FIELDS.flatMap((field) => Object.keys(packageJson[field] ?? {})),
|
|
30
|
+
devDependencies: Object.keys(packageJson.devDependencies ?? {}),
|
|
29
31
|
})
|
|
30
32
|
|
|
31
33
|
/**
|
|
32
|
-
* Collects the workspaces a scope reaches
|
|
34
|
+
* Collects the workspaces a scope reaches, transitively, itself included. The scope's own
|
|
35
|
+
* devDependencies count; a library's do not, since a consumer never installs them: a test
|
|
36
|
+
* dependency of a library on `ui` would otherwise rebuild every service using that library on
|
|
37
|
+
* each `ui` change.
|
|
33
38
|
* @param {string} scopeDir - The scope's workspace directory, e.g. 'apps/web'
|
|
34
39
|
* @param {Map<string, WorkspaceManifest>} manifestsByDir - Every workspace of the pruned tree by directory
|
|
35
40
|
* @returns {Set<string>} Workspace directories
|
|
@@ -47,7 +52,8 @@ const dependencyClosure = (scopeDir, manifestsByDir) => {
|
|
|
47
52
|
const dir = pending.pop()
|
|
48
53
|
if (reached.has(dir)) continue
|
|
49
54
|
reached.add(dir)
|
|
50
|
-
|
|
55
|
+
const { dependencies, devDependencies } = manifestsByDir.get(dir)
|
|
56
|
+
for (const name of dir === scopeDir ? [...dependencies, ...devDependencies] : dependencies) {
|
|
51
57
|
const dependencyDir = dirsByName.get(name)
|
|
52
58
|
if (dependencyDir) pending.push(dependencyDir)
|
|
53
59
|
}
|
|
@@ -33,15 +33,17 @@ const selectUnbuilt = (lookups, forcedShortNames = []) => {
|
|
|
33
33
|
const scopeTagMap = (lookups) => Object.fromEntries(lookups.map(({ scope, imageTag }) => [scope.shortName, imageTag]))
|
|
34
34
|
|
|
35
35
|
/**
|
|
36
|
-
* Lists the scopes whose
|
|
37
|
-
*
|
|
38
|
-
*
|
|
39
|
-
*
|
|
36
|
+
* Lists the scopes whose value in `current` differs from the one in `reference`. Two callers: the
|
|
37
|
+
* matrix compares this commit's tags with the diff base's (a revert lands on an image GHCR already
|
|
38
|
+
* holds, so the matrix stays empty, and this list still makes it deploy); the deploy compares the
|
|
39
|
+
* images it rolls out with the ones the stack runs, to decide which follow-up steps run.
|
|
40
|
+
* @param {Record<string, string>} current - Short name → tag or image now
|
|
41
|
+
* @param {Record<string, string>} reference - Short name → tag or image to compare with; a scope missing here counts as changed
|
|
40
42
|
* @returns {string[]} Short names, sorted
|
|
41
43
|
*/
|
|
42
|
-
const
|
|
43
|
-
Object.keys(
|
|
44
|
-
.filter((shortName) =>
|
|
44
|
+
const changedScopes = (current, reference) =>
|
|
45
|
+
Object.keys(current)
|
|
46
|
+
.filter((shortName) => current[shortName] !== reference[shortName])
|
|
45
47
|
.sort()
|
|
46
48
|
|
|
47
|
-
module.exports = {
|
|
49
|
+
module.exports = { changedScopes, scopeTagMap, selectUnbuilt }
|
|
@@ -151,7 +151,9 @@ runs:
|
|
|
151
151
|
# Bounded: bun install has hung indefinitely after a 404 on a tarball (see above), and an
|
|
152
152
|
# unbounded hang here holds the job until the runner's 6-hour limit. A timeout turns it
|
|
153
153
|
# into the failure path below, which restores the original lockfile.
|
|
154
|
-
|
|
154
|
+
# Without lifecycle scripts: this install only regenerates bun.lock, and a later step of
|
|
155
|
+
# the build job may hold deploy secrets that a dependency's script must not reach.
|
|
156
|
+
NPM_TOKEN="${{ inputs.npm-token }}" timeout 300 bun install --no-progress --ignore-scripts 2>&1 | tee /tmp/bun-install.log; INSTALL_EXIT=${PIPESTATUS[0]}
|
|
155
157
|
[ "$INSTALL_EXIT" -eq 124 ] && echo "::warning::bun install timed out after 300 s"
|
|
156
158
|
set -e
|
|
157
159
|
if [ ! -f bun.lock ] || [ ! -s bun.lock ]; then
|
|
@@ -9,6 +9,12 @@ inputs:
|
|
|
9
9
|
install-command:
|
|
10
10
|
description: Command to install dependencies.
|
|
11
11
|
default: bun install --frozen-lockfile
|
|
12
|
+
ignore-scripts:
|
|
13
|
+
description: >-
|
|
14
|
+
When 'true', runs the install command with --ignore-scripts: no root `prepare` (which builds
|
|
15
|
+
every package) and no dependency lifecycle scripts. For jobs that build what they need
|
|
16
|
+
themselves, or need no built package at all.
|
|
17
|
+
default: 'false'
|
|
12
18
|
enable-cache:
|
|
13
19
|
description: Whether to run the cache step.
|
|
14
20
|
default: 'false'
|
|
@@ -42,13 +48,36 @@ runs:
|
|
|
42
48
|
with:
|
|
43
49
|
bun-version-file: $\{{ inputs.bun-version-file }}
|
|
44
50
|
|
|
51
|
+
# An install without scripts caches a node_modules whose dependencies never ran their lifecycle
|
|
52
|
+
# scripts, and Bun does not run them later for a package already in place. Such caches live under
|
|
53
|
+
# their own prefix, which a restore key of an install with scripts never matches.
|
|
54
|
+
- name: Resolve cache keys
|
|
55
|
+
id: cache-keys
|
|
56
|
+
if: $\{{ inputs.enable-cache == 'true' && inputs.cache-key != '' }}
|
|
57
|
+
shell: bash
|
|
58
|
+
env:
|
|
59
|
+
CACHE_KEY: $\{{ inputs.cache-key }}
|
|
60
|
+
RESTORE_KEYS: $\{{ inputs.cache-restore-keys }}
|
|
61
|
+
IGNORE_SCRIPTS: $\{{ inputs.ignore-scripts }}
|
|
62
|
+
run: |
|
|
63
|
+
PREFIX=""
|
|
64
|
+
[ "$IGNORE_SCRIPTS" = "true" ] && PREFIX="noscripts-"
|
|
65
|
+
echo "key=${PREFIX}${CACHE_KEY}" >> "$GITHUB_OUTPUT"
|
|
66
|
+
{
|
|
67
|
+
echo "restore-keys<<RESTORE_KEYS_END"
|
|
68
|
+
while IFS= read -r line; do
|
|
69
|
+
[ -n "$line" ] && echo "${PREFIX}${line}"
|
|
70
|
+
done <<<"$RESTORE_KEYS"
|
|
71
|
+
echo "RESTORE_KEYS_END"
|
|
72
|
+
} >> "$GITHUB_OUTPUT"
|
|
73
|
+
|
|
45
74
|
- name: Cache dependencies
|
|
46
75
|
if: $\{{ inputs.enable-cache == 'true' && inputs.cache-key != '' }}
|
|
47
76
|
uses: actions/cache@v5
|
|
48
77
|
with:
|
|
49
78
|
path: $\{{ inputs.cache-paths }}
|
|
50
|
-
key: $\{{
|
|
51
|
-
restore-keys: $\{{
|
|
79
|
+
key: $\{{ steps.cache-keys.outputs.key }}
|
|
80
|
+
restore-keys: $\{{ steps.cache-keys.outputs.restore-keys }}
|
|
52
81
|
|
|
53
82
|
# A Bun other than the pinned one writes a lockfile the pinned Bun in the Docker image refuses
|
|
54
83
|
# as changed, so a drifted binary fails here instead of in the image build.
|
|
@@ -67,7 +96,12 @@ runs:
|
|
|
67
96
|
shell: bash
|
|
68
97
|
env:
|
|
69
98
|
NPM_TOKEN: $\{{ inputs.npm-token }}
|
|
99
|
+
IGNORE_SCRIPTS: $\{{ inputs.ignore-scripts }}
|
|
70
100
|
run: |
|
|
71
101
|
set -euo pipefail
|
|
72
102
|
cd "$\{{ inputs.working-directory }}"
|
|
73
|
-
|
|
103
|
+
if [ "$IGNORE_SCRIPTS" = "true" ]; then
|
|
104
|
+
$\{{ inputs.install-command }} --ignore-scripts
|
|
105
|
+
else
|
|
106
|
+
$\{{ inputs.install-command }}
|
|
107
|
+
fi
|
|
@@ -197,18 +197,22 @@ jobs:
|
|
|
197
197
|
# A private workspace package or a lockfile line reaches a scope without
|
|
198
198
|
# touching its directory. On those paths the matrix asks GHCR whether each scope's checksum
|
|
199
199
|
# tag exists instead of diffing directories; a tool-package bump that changes no scope's
|
|
200
|
-
# pruned tree then builds and deploys nothing. A manual dispatch
|
|
200
|
+
# pruned tree then builds and deploys nothing. A manual dispatch asks GHCR too: it rolls out
|
|
201
|
+
# this commit's tags and builds only the images GHCR lacks.
|
|
201
202
|
- name: Decide matrix mode
|
|
202
203
|
id: matrix-mode
|
|
203
204
|
run: |
|
|
204
205
|
EVENT="$\{{ github.event_name }}"
|
|
205
206
|
LOCK_CHANGED=$(git diff --name-only "$GITHUB_EVENT_BEFORE" "$GITHUB_SHA" -- bun.lock | head -1)
|
|
206
|
-
if [ "$EVENT"
|
|
207
|
+
if [ "$EVENT" = "workflow_dispatch" ] || [ "$EVENT" = "workflow_run" ] || [ -n "$LOCK_CHANGED" ]; then
|
|
207
208
|
echo "only_unbuilt=true" >> "$GITHUB_OUTPUT"
|
|
208
209
|
else
|
|
209
210
|
echo "only_unbuilt=false" >> "$GITHUB_OUTPUT"
|
|
210
211
|
fi
|
|
211
212
|
|
|
213
|
+
# No job here reads a built workspace package: prepare-scopes and build only prune, the image
|
|
214
|
+
# installs and builds inside Docker, and the deploy builds the stack's packages itself. The root
|
|
215
|
+
# `prepare` (every package built, about 40 s) is therefore skipped in all three.
|
|
212
216
|
- name: Setup Bun and install dependencies
|
|
213
217
|
if: steps.matrix-mode.outputs.only_unbuilt == 'true'
|
|
214
218
|
uses: ./.github/actions/setup-bun-install
|
|
@@ -217,14 +221,17 @@ jobs:
|
|
|
217
221
|
cache-key: bun-v4-$\{{ runner.os }}-$\{{ hashFiles('bun.lock', 'bunfig.toml') }}
|
|
218
222
|
cache-restore-keys: bun-v4-$\{{ runner.os }}-
|
|
219
223
|
npm-token: $\{{ github.token }}
|
|
224
|
+
ignore-scripts: 'true'
|
|
220
225
|
|
|
226
|
+
# A dispatch deploys in any case (infra-eval sets infrastructure_changed), so it needs no tag
|
|
227
|
+
# comparison with a base worktree.
|
|
221
228
|
- name: Generate scope matrix
|
|
222
229
|
id: scope-generator
|
|
223
230
|
uses: ./.github/actions/generate-scope-matrix
|
|
224
231
|
with:
|
|
225
232
|
scope-roots: $\{{ env.SCOPE_ROOTS }}
|
|
226
|
-
force-all: $\{{ github.event_name == 'workflow_dispatch' }}
|
|
227
233
|
only-unbuilt: $\{{ steps.matrix-mode.outputs.only_unbuilt }}
|
|
234
|
+
compare-base: $\{{ github.event_name != 'workflow_dispatch' }}
|
|
228
235
|
github-token: $\{{ secrets.GITHUB_TOKEN }}
|
|
229
236
|
|
|
230
237
|
build:
|
|
@@ -261,6 +268,7 @@ jobs:
|
|
|
261
268
|
cache-key: bun-v4-$\{{ runner.os }}-$\{{ hashFiles('bun.lock', 'bunfig.toml') }}
|
|
262
269
|
cache-restore-keys: bun-v4-$\{{ runner.os }}-
|
|
263
270
|
npm-token: $\{{ github.token }}
|
|
271
|
+
ignore-scripts: 'true'
|
|
264
272
|
|
|
265
273
|
# The same script computes the tag prepare-scopes looked up (only-unbuilt), so both agree. It
|
|
266
274
|
# also leaves the pruned tree in out/<short-name>, which the Docker build below uses.
|
|
@@ -329,7 +337,10 @@ jobs:
|
|
|
329
337
|
$\{{ env.REGISTRY }}/$\{{ matrix.scope.shortName }}:$\{{ env.IMAGE_TAG }}
|
|
330
338
|
$\{{ env.REGISTRY }}/$\{{ matrix.scope.shortName }}:latest
|
|
331
339
|
cache-from: type=gha,scope=$\{{ matrix.scope.shortName }}
|
|
332
|
-
|
|
340
|
+
# Apps export only the final stage: exporting their builder layers took one to two minutes
|
|
341
|
+
# per build and saved at most the half-minute install, since a client build rarely hits the
|
|
342
|
+
# cache. Services export in seconds and keep mode=max.
|
|
343
|
+
cache-to: type=gha,scope=$\{{ matrix.scope.shortName }},mode=$\{{ startsWith(matrix.scope.dir, 'apps/') && 'min' || 'max' }}
|
|
333
344
|
build-args: |
|
|
334
345
|
BUN_VERSION=$\{{ env.BUN_VERSION }}
|
|
335
346
|
secrets: |
|
|
@@ -392,9 +403,7 @@ jobs:
|
|
|
392
403
|
if [ -f .artifacts/metadata.json ]; then
|
|
393
404
|
SHORT=$(jq -er '.shortName' .artifacts/metadata.json)
|
|
394
405
|
mkdir -p ".artifacts/changed-${SHORT}"
|
|
395
|
-
|
|
396
|
-
if [ -f ".artifacts/${marker_file}" ]; then mv ".artifacts/${marker_file}" ".artifacts/changed-${SHORT}/"; fi
|
|
397
|
-
done
|
|
406
|
+
find .artifacts -maxdepth 1 -type f -exec mv {} ".artifacts/changed-${SHORT}/" \;
|
|
398
407
|
fi
|
|
399
408
|
|
|
400
409
|
- name: Resolve scope image tags
|
|
@@ -458,7 +467,7 @@ jobs:
|
|
|
458
467
|
done
|
|
459
468
|
|
|
460
469
|
if [ -n "$MISSING" ]; then
|
|
461
|
-
echo "::error::No checksum-tagged image found in GHCR for:$MISSING. Refusing to deploy :latest — trigger a build for these scopes first (workflow_dispatch builds
|
|
470
|
+
echo "::error::No checksum-tagged image found in GHCR for:$MISSING. Refusing to deploy :latest — trigger a build for these scopes first (workflow_dispatch builds every missing image)."
|
|
462
471
|
exit 1
|
|
463
472
|
fi
|
|
464
473
|
|
|
@@ -472,6 +481,31 @@ jobs:
|
|
|
472
481
|
cache-key: bun-v4-$\{{ runner.os }}-$\{{ hashFiles('bun.lock', 'bunfig.toml') }}
|
|
473
482
|
cache-restore-keys: bun-v4-$\{{ runner.os }}-
|
|
474
483
|
npm-token: $\{{ github.token }}
|
|
484
|
+
ignore-scripts: 'true'
|
|
485
|
+
|
|
486
|
+
# The scopes this deploy moves to another image than the stack runs, from the stack's
|
|
487
|
+
# `scopeImages` output of the last `up`. The steps that follow a new image (the Studio smoke
|
|
488
|
+
# check among them) hang off this list rather than off a build marker: a dispatch that builds
|
|
489
|
+
# nothing still runs them for the images it changes, and an image built anew but already
|
|
490
|
+
# running skips them. Each changed scope sets `changed_<short-name>=true`; a workspace without
|
|
491
|
+
# a scope never sets its output and skips its steps. A stack that cannot be read fails the step;
|
|
492
|
+
# before the first `up` that writes the output, every scope counts as changed.
|
|
493
|
+
- name: Compare with the running images
|
|
494
|
+
id: rollout
|
|
495
|
+
env:
|
|
496
|
+
PULUMI_ACCESS_TOKEN: $\{{ secrets.PULUMI_ACCESS_TOKEN }}
|
|
497
|
+
DEPLOY_IMAGES: $\{{ steps.resolve_scope_tags.outputs.scope_image_tags }}
|
|
498
|
+
run: |
|
|
499
|
+
set -euo pipefail
|
|
500
|
+
OUTPUTS=$(pulumi stack output --json --cwd infra --stack "$PULUMI_STACK" --non-interactive)
|
|
501
|
+
if ! jq -e 'has("scopeImages")' <<<"$OUTPUTS" >/dev/null; then
|
|
502
|
+
echo "::notice::The stack has no scopeImages output yet; every scope counts as changed."
|
|
503
|
+
fi
|
|
504
|
+
RUNNING_IMAGES=$(jq -c '.scopeImages // {}' <<<"$OUTPUTS")
|
|
505
|
+
export RUNNING_IMAGES
|
|
506
|
+
CHANGED=$(node .github/actions/generate-scope-matrix/rollout-changes.js)
|
|
507
|
+
echo "Rolled out to a new image: ${CHANGED}"
|
|
508
|
+
jq -r '.[] | "changed_\(.)=true"' <<<"$CHANGED" >> "$GITHUB_OUTPUT"
|
|
475
509
|
|
|
476
510
|
- name: Build infrastructure dependencies
|
|
477
511
|
run: |
|
|
@@ -498,18 +532,9 @@ jobs:
|
|
|
498
532
|
DIGITALOCEAN_TOKEN: $\{{ secrets.DIGITALOCEAN_TOKEN }}
|
|
499
533
|
SCOPE_IMAGE_TAGS: $\{{ steps.resolve_scope_tags.outputs.scope_image_tags }}
|
|
500
534
|
|
|
501
|
-
- name: Check if Studio was deployed
|
|
502
|
-
id: studio-deployed
|
|
503
|
-
run: |
|
|
504
|
-
if [ -f .artifacts/changed-studio/metadata.json ]; then
|
|
505
|
-
echo "deployed=true" >> "$GITHUB_OUTPUT"
|
|
506
|
-
else
|
|
507
|
-
echo "deployed=false" >> "$GITHUB_OUTPUT"
|
|
508
|
-
fi
|
|
509
|
-
|
|
510
535
|
- name: Get Pulumi outputs
|
|
511
536
|
id: pulumi-outputs
|
|
512
|
-
if: steps.
|
|
537
|
+
if: steps.rollout.outputs.changed_studio == 'true'
|
|
513
538
|
working-directory: infra
|
|
514
539
|
run: |
|
|
515
540
|
URL=$(pulumi stack output studioUrl --stack "$PULUMI_STACK" --non-interactive)
|
|
@@ -519,7 +544,7 @@ jobs:
|
|
|
519
544
|
|
|
520
545
|
- name: Smoke-check new Studio deployment
|
|
521
546
|
id: studio-smoke
|
|
522
|
-
if: steps.
|
|
547
|
+
if: steps.rollout.outputs.changed_studio == 'true'
|
|
523
548
|
env:
|
|
524
549
|
STUDIO_URL: $\{{ steps.pulumi-outputs.outputs.studio_url }}
|
|
525
550
|
run: |
|
|
@@ -39,6 +39,7 @@ jobs:
|
|
|
39
39
|
patterns: docs/**/*.md
|
|
40
40
|
github-token: $\{{ github.token }}
|
|
41
41
|
|
|
42
|
+
# Lint reads sources only, so the root `prepare` (every package built) is skipped.
|
|
42
43
|
- name: Setup Bun and install dependencies
|
|
43
44
|
if: steps.docs-only.outputs.docs_only != 'true'
|
|
44
45
|
uses: ./.github/actions/setup-bun-install
|
|
@@ -46,6 +47,7 @@ jobs:
|
|
|
46
47
|
enable-cache: 'true'
|
|
47
48
|
cache-key: bun-v4-$\{{ runner.os }}-$\{{ hashFiles('bun.lock', 'bunfig.toml') }}
|
|
48
49
|
cache-restore-keys: bun-v4-$\{{ runner.os }}-
|
|
50
|
+
ignore-scripts: 'true'
|
|
49
51
|
|
|
50
52
|
- name: Run Linter
|
|
51
53
|
if: steps.docs-only.outputs.docs_only != 'true'
|