@alchemy.run/pkg 2.0.0-beta.79 → 2.0.0-beta.81

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.
Files changed (57) hide show
  1. package/README.md +17 -5
  2. package/dist/Manifest.d.ts.map +1 -1
  3. package/dist/Manifest.js.map +1 -1
  4. package/dist/Protocol.d.ts +3 -3
  5. package/dist/Protocol.d.ts.map +1 -1
  6. package/dist/Protocol.js +6 -15
  7. package/dist/Protocol.js.map +1 -1
  8. package/dist/Registry/Bindings.d.ts.map +1 -1
  9. package/dist/Registry/Bindings.js.map +1 -1
  10. package/dist/Registry/GitHub.d.ts +7 -7
  11. package/dist/Registry/GitHub.d.ts.map +1 -1
  12. package/dist/Registry/GitHub.js +20 -45
  13. package/dist/Registry/GitHub.js.map +1 -1
  14. package/dist/Registry/Handler.d.ts +5 -5
  15. package/dist/Registry/Handler.d.ts.map +1 -1
  16. package/dist/Registry/Handler.js +26 -27
  17. package/dist/Registry/Handler.js.map +1 -1
  18. package/dist/Registry/KVCache.d.ts.map +1 -1
  19. package/dist/Registry/KVCache.js +1 -1
  20. package/dist/Registry/KVCache.js.map +1 -1
  21. package/dist/Registry/Sweep.d.ts +1 -1
  22. package/dist/Registry/Sweep.d.ts.map +1 -1
  23. package/dist/Registry/Sweep.js +3 -8
  24. package/dist/Registry/Sweep.js.map +1 -1
  25. package/dist/Registry/Tags.d.ts +8 -8
  26. package/dist/Registry/Tags.d.ts.map +1 -1
  27. package/dist/Registry/Tags.js +1 -1
  28. package/dist/Registry/Tags.js.map +1 -1
  29. package/dist/Registry.d.ts +2 -2
  30. package/dist/Registry.d.ts.map +1 -1
  31. package/dist/Registry.js +1 -1
  32. package/dist/Registry.js.map +1 -1
  33. package/dist/cli/bin.d.ts +8 -4
  34. package/dist/cli/bin.d.ts.map +1 -1
  35. package/dist/cli/bin.js +22 -10
  36. package/dist/cli/bin.js.map +1 -1
  37. package/dist/cli/pack.d.ts +11 -4
  38. package/dist/cli/pack.d.ts.map +1 -1
  39. package/dist/cli/pack.js +91 -30
  40. package/dist/cli/pack.js.map +1 -1
  41. package/dist/cli/publish.d.ts +1 -1
  42. package/dist/cli/publish.d.ts.map +1 -1
  43. package/dist/cli/publish.js +6 -2
  44. package/dist/cli/publish.js.map +1 -1
  45. package/package.json +23 -23
  46. package/src/Manifest.ts +1 -3
  47. package/src/Protocol.ts +15 -31
  48. package/src/Registry/Bindings.ts +1 -4
  49. package/src/Registry/GitHub.ts +34 -86
  50. package/src/Registry/Handler.ts +46 -90
  51. package/src/Registry/KVCache.ts +6 -17
  52. package/src/Registry/Sweep.ts +13 -26
  53. package/src/Registry/Tags.ts +8 -16
  54. package/src/Registry.ts +5 -15
  55. package/src/cli/bin.ts +35 -20
  56. package/src/cli/pack.ts +142 -82
  57. package/src/cli/publish.ts +16 -23
@@ -1,6 +1,6 @@
1
1
  import * as Effect from "effect/Effect";
2
2
  import * as Schema from "effect/Schema";
3
- import * as SqlClient from "effect/unstable/sql/SqlClient";
3
+ import * as SqlClient from "effect/sql/SqlClient";
4
4
 
5
5
  /**
6
6
  * The `tags` table: one row per `(package, tag)` pointing at a tarball hash.
@@ -71,9 +71,7 @@ export const resolve = (pkg: string, tag: string) =>
71
71
  Effect.map(get(pkg, tag), (row) => row?.sha256);
72
72
 
73
73
  /** Rows tied to pull requests whose expiry falls before `before`. */
74
- export const dueLinked = Effect.fn("Tags.dueLinked")(function* (
75
- before: number,
76
- ) {
74
+ export const dueLinked = Effect.fn("Tags.dueLinked")(function* (before: number) {
77
75
  const sql = yield* SqlClient.SqlClient;
78
76
  return yield* decodeTags(
79
77
  yield* sql`
@@ -94,9 +92,7 @@ export const setExpiry = Effect.fn("Tags.setExpiry")(function* (
94
92
  });
95
93
 
96
94
  /** Delete every expired row and return the tarballs those rows pointed at. */
97
- export const deleteExpired = Effect.fn("Tags.deleteExpired")(function* (
98
- now: number,
99
- ) {
95
+ export const deleteExpired = Effect.fn("Tags.deleteExpired")(function* (now: number) {
100
96
  const sql = yield* SqlClient.SqlClient;
101
97
  return yield* decodeTarballs(
102
98
  yield* sql`
@@ -106,12 +102,8 @@ export const deleteExpired = Effect.fn("Tags.deleteExpired")(function* (
106
102
  });
107
103
 
108
104
  /** Every tarball some tag still points at, as `<package>/<sha256>`. */
109
- export const referencedTarballs = Effect.fn("Tags.referencedTarballs")(
110
- function* () {
111
- const sql = yield* SqlClient.SqlClient;
112
- const rows = yield* decodeTarballs(
113
- yield* sql`SELECT DISTINCT package, sha256 FROM tags`,
114
- );
115
- return new Set(rows.map((row) => `${row.package}/${row.sha256}`));
116
- },
117
- );
105
+ export const referencedTarballs = Effect.fn("Tags.referencedTarballs")(function* () {
106
+ const sql = yield* SqlClient.SqlClient;
107
+ const rows = yield* decodeTarballs(yield* sql`SELECT DISTINCT package, sha256 FROM tags`);
108
+ return new Set(rows.map((row) => `${row.package}/${row.sha256}`));
109
+ });
package/src/Registry.ts CHANGED
@@ -20,14 +20,7 @@ import {
20
20
  } from "./Registry/Bindings.ts";
21
21
  import { handler } from "./Registry/Handler.ts";
22
22
 
23
- export {
24
- Bucket,
25
- Cache,
26
- Database,
27
- Policy,
28
- RegistryConfig,
29
- Settings,
30
- } from "./Registry/Bindings.ts";
23
+ export { Bucket, Cache, Database, Policy, RegistryConfig, Settings } from "./Registry/Bindings.ts";
31
24
 
32
25
  export interface RegistryOptions {
33
26
  /**
@@ -99,9 +92,9 @@ const RegistryConfigFromEnv = Layer.effect(
99
92
  RegistryConfig,
100
93
  Effect.gen(function* () {
101
94
  const env = yield* Cloudflare.Workers.WorkerEnvironment;
102
- const settings = yield* Schema.decodeUnknownEffect(Settings)(
103
- env[SETTINGS_ENV],
104
- ).pipe(Effect.orDie);
95
+ const settings = yield* Schema.decodeUnknownEffect(Settings)(env[SETTINGS_ENV]).pipe(
96
+ Effect.orDie,
97
+ );
105
98
  return {
106
99
  ...settings,
107
100
  worker: {},
@@ -165,10 +158,7 @@ export default RegistryLive.pipe(Layer.provide(RegistryConfigFromEnv));
165
158
  * @product Workers
166
159
  * @category Workers & Compute
167
160
  */
168
- export const PkgRegistry = <const Id extends string>(
169
- id: Id,
170
- options: RegistryOptions,
171
- ) =>
161
+ export const PkgRegistry = <const Id extends string>(id: Id, options: RegistryOptions) =>
172
162
  Registry.pipe(
173
163
  Effect.provide(
174
164
  RegistryLive.pipe(
package/src/cli/bin.ts CHANGED
@@ -1,16 +1,11 @@
1
1
  #!/usr/bin/env node
2
2
  import { PlatformServices, runMain } from "alchemy/Util/PlatformServices";
3
+ import { CliConfig, CliError, Command, Flag, GlobalFlag } from "effect/cli";
3
4
  import * as Config from "effect/Config";
4
5
  import * as Effect from "effect/Effect";
6
+ import * as FetchHttpClient from "effect/http/FetchHttpClient";
5
7
  import * as Layer from "effect/Layer";
6
- import {
7
- CliConfig,
8
- CliError,
9
- Command,
10
- Flag,
11
- GlobalFlag,
12
- } from "effect/unstable/cli";
13
- import * as FetchHttpClient from "effect/unstable/http/FetchHttpClient";
8
+ import * as Option from "effect/Option";
14
9
  import pkg from "../../package.json" with { type: "json" };
15
10
  import { Group, pack } from "./pack.ts";
16
11
  import { publish } from "./publish.ts";
@@ -38,11 +33,37 @@ const outFlag = Flag.String("out").pipe(
38
33
 
39
34
  export const packCommand = Command.make(
40
35
  "pack",
41
- { group: groupFlag, registry: registryFlag, out: outFlag },
42
- ({ group, registry, out }) =>
36
+ {
37
+ group: groupFlag,
38
+ registry: registryFlag,
39
+ out: outFlag,
40
+ since: Flag.String("since").pipe(
41
+ Flag.withDescription("Compare HEAD with this Git ref; defaults to the PR base in Actions"),
42
+ Flag.optional,
43
+ ),
44
+ all: Flag.Boolean("all").pipe(
45
+ Flag.withDefault(false),
46
+ Flag.withDescription("Pack every configured package regardless of changes"),
47
+ ),
48
+ rebuildAllPaths: Flag.String("rebuild-all-path").pipe(
49
+ Flag.withDescription(
50
+ "Additional exact path or directory/** forcing all packages (repeatable)",
51
+ ),
52
+ Flag.atLeast(0),
53
+ ),
54
+ },
55
+ ({ group, registry, out, since, all, rebuildAllPaths }) =>
43
56
  Effect.gen(function* () {
44
57
  const cwd = yield* Effect.sync(() => process.cwd());
45
- yield* pack({ cwd, groups: group, registry, out });
58
+ yield* pack({
59
+ cwd,
60
+ groups: group,
61
+ registry,
62
+ out,
63
+ since: Option.getOrUndefined(since),
64
+ all,
65
+ rebuildAllPaths,
66
+ });
46
67
  }),
47
68
  ).pipe(
48
69
  Command.withDescription(
@@ -73,17 +94,13 @@ export const publishCommand = Command.make(
73
94
  Command.withDescription(
74
95
  "Publish a pkg pack directory from the current GitHub Actions job, after its manifest artifact has been uploaded",
75
96
  ),
76
- Command.withExamples([
77
- { command: "pkg publish --registry https://pkg.alchemy.run" },
78
- ]),
97
+ Command.withExamples([{ command: "pkg publish --registry https://pkg.alchemy.run" }]),
79
98
  );
80
99
 
81
100
  export const root = Command.make("pkg", {}, () =>
82
101
  Effect.fail(new CliError.ShowHelp({ commandPath: ["pkg"], errors: [] })),
83
102
  ).pipe(
84
- Command.withDescription(
85
- "Pack and publish preview packages for pull requests.",
86
- ),
103
+ Command.withDescription("Pack and publish preview packages for pull requests."),
87
104
  Command.withSubcommands([packCommand, publishCommand]),
88
105
  );
89
106
 
@@ -92,9 +109,7 @@ Command.run(root, { version: pkg.version }).pipe(
92
109
  Layer.mergeAll(
93
110
  PlatformServices,
94
111
  FetchHttpClient.layer,
95
- CliConfig.layer({
96
- builtIns: [GlobalFlag.Help, GlobalFlag.Version],
97
- }),
112
+ CliConfig.layer({ builtIns: [GlobalFlag.Help, GlobalFlag.Version] }),
98
113
  ),
99
114
  ),
100
115
  runMain,
package/src/cli/pack.ts CHANGED
@@ -1,3 +1,6 @@
1
+ import { gunzipSync, gzipSync } from "node:zlib";
2
+ import { exec } from "alchemy/Util/exec";
3
+ import { sha256 } from "alchemy/Util/sha256";
1
4
  import * as Config from "effect/Config";
2
5
  import * as Console from "effect/Console";
3
6
  import * as Data from "effect/Data";
@@ -5,14 +8,11 @@ import * as Effect from "effect/Effect";
5
8
  import * as FileSystem from "effect/FileSystem";
6
9
  import * as Option from "effect/Option";
7
10
  import * as Path from "effect/Path";
11
+ import * as ChildProcess from "effect/process/ChildProcess";
8
12
  import * as Schema from "effect/Schema";
9
13
  import * as SchemaIssue from "effect/SchemaIssue";
10
14
  import * as SchemaTransformation from "effect/SchemaTransformation";
11
- import * as ChildProcess from "effect/unstable/process/ChildProcess";
12
- import { exec } from "alchemy/Util/exec";
13
- import { sha256 } from "alchemy/Util/sha256";
14
15
  import { packTar, unpackTar, type TarHeader } from "modern-tar";
15
- import { gunzipSync, gzipSync } from "node:zlib";
16
16
  import {
17
17
  GroupName,
18
18
  MANIFEST_FILE,
@@ -48,8 +48,7 @@ export const Group = Schema.String.pipe(
48
48
  .split(",")
49
49
  .map((attribute) => attribute.trim())
50
50
  .filter((attribute) => attribute.length > 0);
51
- return match &&
52
- attributes.every((attribute) => attribute === "Collapsed")
51
+ return match && attributes.every((attribute) => attribute === "Collapsed")
53
52
  ? Effect.succeed({
54
53
  name: match[1]!,
55
54
  pattern: match[3]!,
@@ -64,9 +63,7 @@ export const Group = Schema.String.pipe(
64
63
  );
65
64
  },
66
65
  encode: (group) =>
67
- Effect.succeed(
68
- `${group.name}${group.collapsed ? "[Collapsed]" : ""}=${group.pattern}`,
69
- ),
66
+ Effect.succeed(`${group.name}${group.collapsed ? "[Collapsed]" : ""}=${group.pattern}`),
70
67
  }),
71
68
  ),
72
69
  );
@@ -86,9 +83,7 @@ export const DEPENDENCY_SECTIONS = [
86
83
  "optionalDependencies",
87
84
  ] as const;
88
85
 
89
- const DependencyMap = Schema.optionalKey(
90
- Schema.Record(Schema.String, Schema.String),
91
- );
86
+ const DependencyMap = Schema.optionalKey(Schema.Record(Schema.String, Schema.String));
92
87
 
93
88
  export const DependencySections = Schema.Struct({
94
89
  dependencies: DependencyMap,
@@ -140,6 +135,70 @@ export interface WorkspacePackage {
140
135
  readonly group: string;
141
136
  }
142
137
 
138
+ /** Shared inputs that invalidate every configured package. */
139
+ export const REBUILD_ALL_PATHS = [
140
+ "package.json",
141
+ "pnpm-lock.yaml",
142
+ "pnpm-workspace.yaml",
143
+ "package-lock.json",
144
+ "yarn.lock",
145
+ "bun.lock",
146
+ "bun.lockb",
147
+ "tsconfig.json",
148
+ "tsconfig.base.json",
149
+ "turbo.json",
150
+ ".github/workflows/pkg.yml",
151
+ ".github/workflows/pr-package.yml",
152
+ ];
153
+
154
+ /** Select dependents first, then dependencies, without pulling in unrelated siblings. */
155
+ export const selectPackages = (
156
+ packages: ReadonlyArray<WorkspacePackage>,
157
+ dependencies: ReadonlyMap<string, ReadonlySet<string>>,
158
+ changedFiles: ReadonlyArray<string>,
159
+ rebuildAllPaths: ReadonlyArray<string> = [],
160
+ ) => {
161
+ if (
162
+ changedFiles.some((file) =>
163
+ [...REBUILD_ALL_PATHS, ...rebuildAllPaths].some((pattern) =>
164
+ pattern.endsWith("/**")
165
+ ? file === pattern.slice(0, -3) || file.startsWith(pattern.slice(0, -2))
166
+ : file === pattern,
167
+ ),
168
+ )
169
+ )
170
+ return [...packages];
171
+
172
+ const selected = new Set(
173
+ packages
174
+ .filter((pkg) =>
175
+ changedFiles.some(
176
+ (file) =>
177
+ file === pkg.dir || file.startsWith(`${pkg.dir}/`) || pkg.dir.startsWith(`${file}/`),
178
+ ),
179
+ )
180
+ .map((pkg) => pkg.name),
181
+ );
182
+ // A changed gitlink selects the packages nested inside that submodule.
183
+ let size: number;
184
+ do {
185
+ size = selected.size;
186
+ for (const [name, deps] of dependencies) {
187
+ if ([...deps].some((dep) => selected.has(dep))) selected.add(name);
188
+ }
189
+ } while (selected.size !== size);
190
+ const pending = [...selected];
191
+ for (const name of pending) {
192
+ for (const dep of dependencies.get(name) ?? []) {
193
+ if (!selected.has(dep)) {
194
+ selected.add(dep);
195
+ pending.push(dep);
196
+ }
197
+ }
198
+ }
199
+ return packages.filter((pkg) => selected.has(pkg.name));
200
+ };
201
+
143
202
  /**
144
203
  * Expand one level of `{a,b,c}` alternatives into plain patterns, so
145
204
  * `./packages/{alchemy,pkg}` lists exactly those two directories.
@@ -151,9 +210,7 @@ export const expandBraces = (pattern: string): string[] => {
151
210
  .split(",")
152
211
  .map((alternative) => alternative.trim())
153
212
  .filter((alternative) => alternative.length > 0)
154
- .flatMap((alternative) =>
155
- expandBraces(`${match[1]}${alternative}${match[3]}`),
156
- );
213
+ .flatMap((alternative) => expandBraces(`${match[1]}${alternative}${match[3]}`));
157
214
  };
158
215
 
159
216
  /**
@@ -169,10 +226,7 @@ const expand = Effect.fn("expandGlob")(function* (cwd: string, glob: string) {
169
226
  return [...new Set(results)];
170
227
  });
171
228
 
172
- const expandPattern = Effect.fn("expandPattern")(function* (
173
- cwd: string,
174
- pattern: string,
175
- ) {
229
+ const expandPattern = Effect.fn("expandPattern")(function* (cwd: string, pattern: string) {
176
230
  const fs = yield* FileSystem.FileSystem;
177
231
  const path = yield* Path.Path;
178
232
  if (pattern.includes("**")) {
@@ -257,10 +311,7 @@ export class GitError extends Data.TaggedError("GitError")<{
257
311
  }
258
312
 
259
313
  /** Run `git` in `cwd` and return trimmed stdout. */
260
- export const git = Effect.fn("git")(function* (
261
- cwd: string,
262
- args: ReadonlyArray<string>,
263
- ) {
314
+ export const git = Effect.fn("git")(function* (cwd: string, args: ReadonlyArray<string>) {
264
315
  const { exitCode, stdout, stderr } = yield* exec(
265
316
  ChildProcess.make("git", [...args], { cwd, shell: false }),
266
317
  ).pipe(Effect.scoped);
@@ -271,8 +322,7 @@ export const git = Effect.fn("git")(function* (
271
322
  });
272
323
 
273
324
  /** Absolute path of the repository (or submodule) that owns `cwd`. */
274
- export const toplevel = (cwd: string) =>
275
- git(cwd, ["rev-parse", "--show-toplevel"]);
325
+ export const toplevel = (cwd: string) => git(cwd, ["rev-parse", "--show-toplevel"]);
276
326
 
277
327
  /** Full HEAD SHA of the repository that owns `cwd`. */
278
328
  const gitHead = (cwd: string) => git(cwd, ["rev-parse", "HEAD"]);
@@ -282,24 +332,17 @@ export class PackError extends Data.TaggedError("PackError")<{
282
332
  readonly message: string;
283
333
  }> {}
284
334
 
285
- const PnpmPackOutput = Schema.fromJsonString(
286
- Schema.Struct({ filename: Schema.String }),
287
- );
335
+ const PnpmPackOutput = Schema.fromJsonString(Schema.Struct({ filename: Schema.String }));
288
336
 
289
337
  /**
290
338
  * Rewrite every dependency on a package in `links` to that package's
291
339
  * tarball URL. Returns the rewritten manifest text and the rewrites made.
292
340
  */
293
- export const rewriteDependencies = (
294
- manifestText: string,
295
- links: ReadonlyMap<string, string>,
296
- ) =>
341
+ export const rewriteDependencies = (manifestText: string, links: ReadonlyMap<string, string>) =>
297
342
  Effect.gen(function* () {
298
343
  const manifest = yield* Schema.decodeUnknownEffect(
299
344
  Schema.fromJsonString(
300
- Schema.StructWithRest(DependencySections, [
301
- Schema.Record(Schema.String, Schema.Unknown),
302
- ]),
345
+ Schema.StructWithRest(DependencySections, [Schema.Record(Schema.String, Schema.Unknown)]),
303
346
  ),
304
347
  )(manifestText);
305
348
  // Preserve source key order after validation: it contributes to the tarball hash.
@@ -387,16 +430,12 @@ export const packPackage = Effect.fn("packPackage")(function* (options: {
387
430
 
388
431
  let rewrites: PackedTarball["rewrites"] = [];
389
432
  const normalized: Array<{ header: TarHeader; data: Uint8Array }> = [];
390
- for (const entry of entries.sort((a, b) =>
391
- a.header.name.localeCompare(b.header.name),
392
- )) {
433
+ for (const entry of entries.sort((a, b) => a.header.name.localeCompare(b.header.name))) {
393
434
  let data = entry.data ?? new Uint8Array();
394
435
  if (entry.header.name === "package/package.json") {
395
436
  const text = yield* Effect.sync(() => new TextDecoder().decode(data));
396
437
  const result = yield* rewriteDependencies(text, options.links).pipe(
397
- Effect.mapError(
398
- (e) => new PackError({ dir: options.absDir, message: String(e) }),
399
- ),
438
+ Effect.mapError((e) => new PackError({ dir: options.absDir, message: String(e) })),
400
439
  );
401
440
  rewrites = result.rewrites;
402
441
  data = yield* Effect.sync(() => new TextEncoder().encode(result.text));
@@ -418,31 +457,31 @@ export const packPackage = Effect.fn("packPackage")(function* (options: {
418
457
  const PullRequestEvent = Schema.fromJsonString(
419
458
  Schema.Struct({
420
459
  pull_request: Schema.optionalKey(
421
- Schema.Struct({ head: Schema.Struct({ sha: Schema.String }) }),
460
+ Schema.Struct({
461
+ head: Schema.Struct({ sha: Schema.String }),
462
+ base: Schema.Struct({ sha: Schema.String }),
463
+ }),
422
464
  ),
423
465
  }),
424
466
  );
425
467
 
426
468
  /**
427
- * The pull request head commit when running under a GitHub Actions
428
- * `pull_request` event, read from the event payload. `undefined` elsewhere.
469
+ * Pull request metadata from the GitHub Actions event payload.
470
+ * `undefined` outside pull request events.
429
471
  * On that event the default checkout is a synthetic merge commit, which the
430
472
  * registry would reject because it does not match the run's head.
431
473
  */
432
- const pullRequestHead = Effect.gen(function* () {
474
+ const pullRequest = Effect.gen(function* () {
433
475
  const event = yield* Config.option(Config.String("GITHUB_EVENT_NAME"));
434
476
  const eventPath = yield* Config.option(Config.String("GITHUB_EVENT_PATH"));
435
- if (
436
- Option.getOrUndefined(event) !== "pull_request" ||
437
- Option.isNone(eventPath)
438
- ) {
477
+ if (Option.getOrUndefined(event) !== "pull_request" || Option.isNone(eventPath)) {
439
478
  return undefined;
440
479
  }
441
480
  const fs = yield* FileSystem.FileSystem;
442
481
  const payload = yield* fs
443
482
  .readFileString(eventPath.value)
444
483
  .pipe(Effect.flatMap(Schema.decodeUnknownEffect(PullRequestEvent)));
445
- return payload.pull_request?.head.sha;
484
+ return payload.pull_request;
446
485
  });
447
486
 
448
487
  export interface PackOptions {
@@ -450,6 +489,9 @@ export interface PackOptions {
450
489
  readonly groups: ReadonlyArray<Group>;
451
490
  readonly registry: string;
452
491
  readonly out: string;
492
+ readonly since?: string;
493
+ readonly all?: boolean;
494
+ readonly rebuildAllPaths?: ReadonlyArray<string>;
453
495
  }
454
496
 
455
497
  /**
@@ -457,8 +499,7 @@ export interface PackOptions {
457
499
  * replaced by `+`, a character package names cannot contain, so `@a/b-c`
458
500
  * and `@a-b/c` get distinct files.
459
501
  */
460
- export const tarballFile = (name: string) =>
461
- `${name.replace(/^@/, "").replace("/", "+")}.tgz`;
502
+ export const tarballFile = (name: string) => `${name.replace(/^@/, "").replace("/", "+")}.tgz`;
462
503
 
463
504
  /**
464
505
  * Pack every discovered package into `out` with a manifest describing each
@@ -470,7 +511,8 @@ export const pack = Effect.fn("pack")(function* (options: PackOptions) {
470
511
 
471
512
  const root = yield* toplevel(options.cwd);
472
513
  const head = yield* gitHead(root);
473
- const prHead = yield* pullRequestHead;
514
+ const pr = yield* pullRequest;
515
+ const prHead = pr?.head.sha;
474
516
  if (prHead !== undefined && prHead !== head) {
475
517
  return yield* new WorkspaceError({
476
518
  message:
@@ -478,11 +520,7 @@ export const pack = Effect.fn("pack")(function* (options: PackOptions) {
478
520
  "Check out github.event.pull_request.head.sha before packing so tarballs are addressed by a commit that exists on the pull request.",
479
521
  });
480
522
  }
481
- const packages = yield* discover(options.cwd, options.groups);
482
- if (packages.length === 0) {
483
- yield* Console.log("No publishable packages matched.");
484
- return undefined;
485
- }
523
+ let packages = yield* discover(options.cwd, options.groups);
486
524
 
487
525
  // Dependencies between packed packages are rewritten to the dependency's
488
526
  // immutable tarball URL, so a package is packed only after everything it
@@ -492,31 +530,55 @@ export const pack = Effect.fn("pack")(function* (options: PackOptions) {
492
530
  for (const pkg of packages) {
493
531
  const manifest = yield* fs
494
532
  .readFileString(path.join(pkg.absDir, "package.json"))
495
- .pipe(
496
- Effect.flatMap(
497
- Schema.decodeUnknownEffect(Schema.fromJsonString(DependencySections)),
498
- ),
499
- );
533
+ .pipe(Effect.flatMap(Schema.decodeUnknownEffect(Schema.fromJsonString(DependencySections))));
500
534
  dependencies.set(
501
535
  pkg.name,
502
536
  new Set(
503
- DEPENDENCY_SECTIONS.flatMap((section) =>
504
- Object.keys(manifest[section] ?? {}),
505
- ).filter((name) => name !== pkg.name && byName.has(name)),
537
+ DEPENDENCY_SECTIONS.flatMap((section) => Object.keys(manifest[section] ?? {})).filter(
538
+ (name) => name !== pkg.name && byName.has(name),
539
+ ),
506
540
  ),
507
541
  );
508
542
  }
543
+ const since = options.all ? undefined : (options.since ?? pr?.base.sha);
544
+ if (since !== undefined) {
545
+ // Disable rename detection so moves affect both the old and new packages.
546
+ const changed = (yield* git(root, [
547
+ "diff",
548
+ "--name-only",
549
+ "--no-renames",
550
+ "-z",
551
+ since,
552
+ head,
553
+ "--",
554
+ ]))
555
+ .split("\0")
556
+ .filter(Boolean);
557
+ const selected = selectPackages(
558
+ packages.map((pkg) => ({
559
+ ...pkg,
560
+ dir: path.relative(root, pkg.absDir).split(path.sep).join("/"),
561
+ })),
562
+ dependencies,
563
+ changed,
564
+ options.rebuildAllPaths,
565
+ );
566
+ const names = new Set(selected.map((pkg) => pkg.name));
567
+ packages = packages.filter((pkg) => names.has(pkg.name));
568
+ for (const name of dependencies.keys()) {
569
+ if (!names.has(name)) dependencies.delete(name);
570
+ }
571
+ yield* Console.log(
572
+ `Selected ${packages.length} package(s) changed since ${since}, including dependents and dependencies.`,
573
+ );
574
+ }
509
575
  const levels = yield* dependencyLevels(dependencies);
510
576
 
511
577
  // The output directory is replaced wholesale, so it has to be a proper
512
578
  // subdirectory of the workspace: `--out .` would otherwise delete it.
513
579
  const outDir = path.resolve(options.cwd, options.out);
514
580
  const relative = path.relative(options.cwd, outDir);
515
- if (
516
- relative === "" ||
517
- relative.startsWith("..") ||
518
- path.isAbsolute(relative)
519
- ) {
581
+ if (relative === "" || relative.startsWith("..") || path.isAbsolute(relative)) {
520
582
  return yield* new WorkspaceError({
521
583
  message: `--out must be a subdirectory of ${options.cwd}, got ${outDir}`,
522
584
  });
@@ -555,10 +617,7 @@ export const pack = Effect.fn("pack")(function* (options: PackOptions) {
555
617
  { concurrency: 4 },
556
618
  );
557
619
  for (const entry of packedLevel) {
558
- links.set(
559
- entry.name,
560
- tarballUrl(options.registry, entry.name, entry.sha256),
561
- );
620
+ links.set(entry.name, tarballUrl(options.registry, entry.name, entry.sha256));
562
621
  packedByName.set(entry.name, entry);
563
622
  }
564
623
  }
@@ -568,10 +627,7 @@ export const pack = Effect.fn("pack")(function* (options: PackOptions) {
568
627
  // One entry per distinct group name, in the order the groups were given.
569
628
  const groups = [
570
629
  ...new Map(
571
- options.groups.map((group) => [
572
- group.name,
573
- { name: group.name, collapsed: group.collapsed },
574
- ]),
630
+ options.groups.map((group) => [group.name, { name: group.name, collapsed: group.collapsed }]),
575
631
  ).values(),
576
632
  ];
577
633
  const manifest: Manifest = {
@@ -594,9 +650,13 @@ export const pack = Effect.fn("pack")(function* (options: PackOptions) {
594
650
  const artifact = manifestArtifactName(yield* sha256(manifestText));
595
651
  const stepOutput = yield* Config.option(Config.String("GITHUB_OUTPUT"));
596
652
  if (Option.isSome(stepOutput)) {
597
- yield* fs.writeFileString(stepOutput.value, `artifact-name=${artifact}\n`, {
598
- flag: "a",
599
- });
653
+ yield* fs.writeFileString(
654
+ stepOutput.value,
655
+ `artifact-name=${artifact}\npackage-count=${entries.length}\n`,
656
+ {
657
+ flag: "a",
658
+ },
659
+ );
600
660
  }
601
661
  yield* Console.log(`Manifest artifact name: ${artifact}`);
602
662
  return manifest;
@@ -3,9 +3,9 @@ import * as Console from "effect/Console";
3
3
  import * as Data from "effect/Data";
4
4
  import * as Effect from "effect/Effect";
5
5
  import * as FileSystem from "effect/FileSystem";
6
+ import * as HttpApiClient from "effect/http-api/HttpApiClient";
6
7
  import * as Path from "effect/Path";
7
8
  import * as Schema from "effect/Schema";
8
- import * as HttpApiClient from "effect/unstable/httpapi/HttpApiClient";
9
9
  import { MANIFEST_FILE, ManifestJson, type Manifest } from "../Manifest.ts";
10
10
  import { PkgApi, type TarballRef } from "../Protocol.ts";
11
11
 
@@ -25,9 +25,7 @@ export interface PublishOptions {
25
25
  const currentRun = Effect.gen(function* () {
26
26
  const repo = yield* Config.String("GITHUB_REPOSITORY");
27
27
  const runId = yield* Config.Int("GITHUB_RUN_ID");
28
- const attempt = yield* Config.Int("GITHUB_RUN_ATTEMPT").pipe(
29
- Config.withDefault(1),
30
- );
28
+ const attempt = yield* Config.Int("GITHUB_RUN_ATTEMPT").pipe(Config.withDefault(1));
31
29
  return { repo, runId, attempt };
32
30
  }).pipe(
33
31
  Effect.mapError(
@@ -39,11 +37,10 @@ const currentRun = Effect.gen(function* () {
39
37
  );
40
38
 
41
39
  /** Render a registry or transport failure as a `PublishError`. */
42
- const failed =
43
- (what: string) => (e: { readonly _tag: string; readonly message?: string }) =>
44
- new PublishError({
45
- message: `${what}: ${e.message ? `${e._tag}: ${e.message}` : e._tag}`,
46
- });
40
+ const failed = (what: string) => (e: { readonly _tag: string; readonly message?: string }) =>
41
+ new PublishError({
42
+ message: `${what}: ${e.message ? `${e._tag}: ${e.message}` : e._tag}`,
43
+ });
47
44
 
48
45
  /**
49
46
  * Publish a `pkg pack` directory from the current GitHub Actions job. The
@@ -52,9 +49,7 @@ const failed =
52
49
  * through GitHub, then either reports the tarballs it lacks, which are
53
50
  * uploaded before trying again, or writes the tags.
54
51
  */
55
- export const publish = Effect.fn("publish")(function* (
56
- options: PublishOptions,
57
- ) {
52
+ export const publish = Effect.fn("publish")(function* (options: PublishOptions) {
58
53
  const fs = yield* FileSystem.FileSystem;
59
54
  const path = yield* Path.Path;
60
55
  const registry = options.registry.replace(/\/+$/, "");
@@ -63,14 +58,18 @@ export const publish = Effect.fn("publish")(function* (
63
58
 
64
59
  const dir = path.resolve(options.cwd, options.dir);
65
60
  const manifestText = yield* fs.readFileString(path.join(dir, MANIFEST_FILE));
66
- const manifest =
67
- yield* Schema.decodeUnknownEffect(ManifestJson)(manifestText);
61
+ const manifest = yield* Schema.decodeUnknownEffect(ManifestJson)(manifestText);
68
62
  if (manifest.registry !== registry) {
69
63
  return yield* new PublishError({
70
64
  message: `artifact was packed for ${manifest.registry}, not ${registry}`,
71
65
  });
72
66
  }
73
67
 
68
+ if (manifest.packages.length === 0) {
69
+ yield* Console.log("No affected packages to publish.");
70
+ return { packages: [] };
71
+ }
72
+
74
73
  const upload = (pkg: Manifest["packages"][number]) =>
75
74
  Effect.gen(function* () {
76
75
  const bytes = yield* fs.readFile(path.join(dir, pkg.file));
@@ -91,9 +90,7 @@ export const publish = Effect.fn("publish")(function* (
91
90
  ).pipe(
92
91
  Effect.andThen(
93
92
  Effect.forEach(
94
- manifest.packages.filter((pkg) =>
95
- wanted.has(`${pkg.name}@${pkg.sha256}`),
96
- ),
93
+ manifest.packages.filter((pkg) => wanted.has(`${pkg.name}@${pkg.sha256}`)),
97
94
  upload,
98
95
  { concurrency: 4, discard: true },
99
96
  ),
@@ -107,9 +104,7 @@ export const publish = Effect.fn("publish")(function* (
107
104
  const published = yield* attempt.pipe(
108
105
  // The first answer is usually the tarballs to upload; publish once more
109
106
  // after uploading them.
110
- Effect.catchTag("MissingTarballs", (e) =>
111
- Effect.andThen(uploadMissing(e.missing), attempt),
112
- ),
107
+ Effect.catchTag("MissingTarballs", (e) => Effect.andThen(uploadMissing(e.missing), attempt)),
113
108
  Effect.catchTag(
114
109
  "MissingTarballs",
115
110
  (e) =>
@@ -117,9 +112,7 @@ export const publish = Effect.fn("publish")(function* (
117
112
  message: `registry still reports missing tarballs after upload: ${e.missing.map((ref) => ref.name).join(", ")}`,
118
113
  }),
119
114
  ),
120
- Effect.mapError((e) =>
121
- e._tag === "PublishError" ? e : failed("publish")(e),
122
- ),
115
+ Effect.mapError((e) => (e._tag === "PublishError" ? e : failed("publish")(e))),
123
116
  );
124
117
 
125
118
  for (const pkg of published.packages) {