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

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.
@@ -4,7 +4,6 @@ import * as Apps from "@distilled.cloud/github/apps";
4
4
  import * as Checks from "@distilled.cloud/github/checks";
5
5
  import * as Issues from "@distilled.cloud/github/issues";
6
6
  import * as Pulls from "@distilled.cloud/github/pulls";
7
- import * as Repos from "@distilled.cloud/github/repos";
8
7
  import * as Arr from "effect/Array";
9
8
  import * as Clock from "effect/Clock";
10
9
  import * as Context from "effect/Context";
@@ -12,13 +11,13 @@ import * as Data from "effect/Data";
12
11
  import * as DateTime from "effect/DateTime";
13
12
  import * as Duration from "effect/Duration";
14
13
  import * as Effect from "effect/Effect";
15
- import * as Encoding from "effect/Encoding";
14
+ import { Base64Url } from "effect/encoding";
16
15
  import * as Layer from "effect/Layer";
17
16
  import * as Option from "effect/Option";
18
17
  import * as Redacted from "effect/Redacted";
19
18
  import * as Schema from "effect/Schema";
20
19
  import * as Stream from "effect/Stream";
21
- import * as HttpClient from "effect/unstable/http/HttpClient";
20
+ import * as HttpClient from "effect/http/HttpClient";
22
21
  import * as crypto from "node:crypto";
23
22
  import { COMMENT_MARKER, RegistryConfig } from "./Bindings.ts";
24
23
  import * as KVCache from "./KVCache.ts";
@@ -50,13 +49,13 @@ export const signJwt = (
50
49
  ) =>
51
50
  Effect.try({
52
51
  try: () => {
53
- const header = Encoding.encodeBase64Url(
52
+ const header = Base64Url.encode(
54
53
  JSON.stringify({ alg: "RS256", typ: "JWT" }),
55
54
  );
56
- const payload = Encoding.encodeBase64Url(JSON.stringify(claims));
55
+ const payload = Base64Url.encode(JSON.stringify(claims));
57
56
  const input = `${header}.${payload}`;
58
57
  const signature = crypto.sign("sha256", Buffer.from(input), key);
59
- return `${input}.${Encoding.encodeBase64Url(new Uint8Array(signature))}`;
58
+ return `${input}.${Base64Url.encode(new Uint8Array(signature))}`;
60
59
  },
61
60
  catch: (cause) => new CryptoError({ message: `signing failed: ${cause}` }),
62
61
  });
@@ -243,15 +242,22 @@ const make = Effect.gen(function* () {
243
242
  ).pipe(Effect.map((page) => page.artifacts)),
244
243
 
245
244
  /**
246
- * Pull requests against `repo` whose head is `sha` in `headRepo`, the
247
- * repository the commit was pushed to.
245
+ * Open pull requests against `repo` whose head is `sha` in `headRepo`, the
246
+ * repository the commit was pushed to. Commit-associated PR lookups can
247
+ * return no results for forks, so discover candidates by owner and branch.
248
248
  */
249
- pullRequestsForCommit: (repo: string, headRepo: string, sha: string) =>
249
+ pullRequestsForCommit: (
250
+ repo: string,
251
+ headRepo: string,
252
+ headBranch: string,
253
+ sha: string,
254
+ ) =>
250
255
  asInstallation(
251
256
  repo,
252
- Repos.listPullRequestsAssociatedWithCommit({
257
+ Pulls.list({
253
258
  ...split(repo),
254
- commit_sha: sha,
259
+ head: `${split(headRepo).owner}:${headBranch}`,
260
+ state: "open",
255
261
  per_page: 100,
256
262
  }),
257
263
  ).pipe(
@@ -259,6 +265,7 @@ const make = Effect.gen(function* () {
259
265
  pulls.filter(
260
266
  (pr) =>
261
267
  pr.head.sha === sha &&
268
+ pr.head.ref === headBranch &&
262
269
  pr.head.repo?.full_name === headRepo &&
263
270
  pr.base.repo.full_name === repo,
264
271
  ),
@@ -4,18 +4,18 @@ import { sha256 } from "alchemy/Util/sha256";
4
4
  import * as Clock from "effect/Clock";
5
5
  import * as Duration from "effect/Duration";
6
6
  import * as Effect from "effect/Effect";
7
- import * as Encoding from "effect/Encoding";
7
+ import { Hex } from "effect/encoding";
8
8
  import * as Layer from "effect/Layer";
9
9
  import * as Path from "effect/Path";
10
10
  import * as Result from "effect/Result";
11
11
  import * as Schema from "effect/Schema";
12
- import * as Etag from "effect/unstable/http/Etag";
13
- import * as FetchHttpClient from "effect/unstable/http/FetchHttpClient";
14
- import * as HttpPlatform from "effect/unstable/http/HttpPlatform";
15
- import * as HttpRouter from "effect/unstable/http/HttpRouter";
16
- import { HttpServerRequest } from "effect/unstable/http/HttpServerRequest";
17
- import * as HttpServerResponse from "effect/unstable/http/HttpServerResponse";
18
- import * as HttpApiBuilder from "effect/unstable/httpapi/HttpApiBuilder";
12
+ import * as Etag from "effect/http/Etag";
13
+ import * as FetchHttpClient from "effect/http/FetchHttpClient";
14
+ import * as HttpPlatform from "effect/http/HttpPlatform";
15
+ import * as HttpRouter from "effect/http/HttpRouter";
16
+ import { HttpServerRequest } from "effect/http/HttpServerRequest";
17
+ import * as HttpServerResponse from "effect/http/HttpServerResponse";
18
+ import * as HttpApiBuilder from "effect/http-api/HttpApiBuilder";
19
19
  import { ManifestJson, type ManifestPackage } from "../Manifest.ts";
20
20
  import {
21
21
  BadRequest,
@@ -81,24 +81,26 @@ const isFork = (run: Run) => run.headRepo !== run.repo;
81
81
  /**
82
82
  * Tags every package in a publication receives, all derived from the run.
83
83
  * Runs on the repository's own commits get the head commit, the short
84
- * commit, `branch:<name>`, and `pr:N` for pull requests. A fork's run gets
85
- * only `pr:N`: commit tags are shared by every publisher, and a fork can
84
+ * commit, `branch:<name>`, and `pr:N` plus `pr:N:<short-sha>` for pull requests.
85
+ * A fork's run gets only `pr:N:<short-sha>`: commit tags are shared by every publisher, and a fork can
86
86
  * run any commit it likes, including one already published from the
87
87
  * repository, so letting it write them would let it repoint them.
88
88
  */
89
89
  export const tagsFor = (run: Run): string[] => {
90
90
  if (isFork(run)) {
91
- return run.pr === null ? [] : [`pr:${run.pr}`];
91
+ return run.pr === null ? [] : [installTag(run)];
92
92
  }
93
93
  const tags = [run.headSha, run.headSha.slice(0, SHORT)];
94
- if (run.pr !== null) tags.push(`pr:${run.pr}`);
94
+ if (run.pr !== null) tags.push(`pr:${run.pr}`, installTag(run));
95
95
  if (run.headBranch) tags.push(`branch:${run.headBranch}`);
96
96
  return tags;
97
97
  };
98
98
 
99
99
  /** The tag install commands are written against. */
100
100
  export const installTag = (run: Run) =>
101
- isFork(run) ? `pr:${run.pr}` : run.headSha.slice(0, SHORT);
101
+ run.pr !== null
102
+ ? `pr:${run.pr}:${run.headSha.slice(0, SHORT)}`
103
+ : run.headSha.slice(0, SHORT);
102
104
 
103
105
  const upstream = (e: { readonly _tag: string; readonly message?: string }) =>
104
106
  new Upstream({ message: GitHub.describe(e) });
@@ -138,18 +140,28 @@ const lookupRun = Effect.fn("lookupRun")(function* (ref: RunRef) {
138
140
  const headRepo = data.head_repository?.full_name ?? data.repository.full_name;
139
141
  let pr: number | null = null;
140
142
  if (data.event === "pull_request") {
143
+ if (data.head_branch === null) {
144
+ return yield* new BadRequest({
145
+ message: `pull request run ${ref.runId} has no head branch`,
146
+ });
147
+ }
141
148
  // The pull request whose head is this run's head, from the repository
142
149
  // the run's head lives in. The same commit can head several pull
143
150
  // requests, including one opened from a fork against a commit the
144
151
  // repository already published; matching the head repository keeps a
145
152
  // run's publication on its own pull request.
146
153
  const pulls = yield* github
147
- .pullRequestsForCommit(ref.repo, headRepo, data.head_sha)
154
+ .pullRequestsForCommit(
155
+ ref.repo,
156
+ headRepo,
157
+ data.head_branch,
158
+ data.head_sha,
159
+ )
148
160
  .pipe(Effect.mapError(upstream));
149
161
  const first = pulls[0];
150
162
  if (first === undefined) {
151
163
  return yield* new BadRequest({
152
- message: `no pull request from ${headRepo} has head ${data.head_sha}`,
164
+ message: `no open pull request from ${headRepo} has head ${data.head_sha}`,
153
165
  });
154
166
  }
155
167
  pr = first.number;
@@ -339,7 +351,7 @@ const uploadTarball = Effect.fn("uploadTarball")(function* (
339
351
  .put(key, request.stream, {
340
352
  contentLength,
341
353
  // The params schema already constrained this to 64 hex chars.
342
- sha256: Result.getOrThrow(Encoding.decodeHex(sha256)),
354
+ sha256: Result.getOrThrow(Hex.decode(sha256)),
343
355
  })
344
356
  .pipe(
345
357
  Effect.mapError(
@@ -444,7 +456,7 @@ const install = Effect.gen(function* () {
444
456
 
445
457
  const banner = Effect.map(origin, (base) =>
446
458
  HttpServerResponse.text(
447
- `Preview package registry. Install with: bun add ${base}/<package>/<commit|branch:name|pr:N>\n`,
459
+ `Preview package registry. Install with: bun add ${base}/<package>/<commit|branch:name|pr:N|pr:N:short-sha>\n`,
448
460
  ),
449
461
  );
450
462
 
@@ -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.
package/src/cli/bin.ts CHANGED
@@ -2,15 +2,10 @@
2
2
  import { PlatformServices, runMain } from "alchemy/Util/PlatformServices";
3
3
  import * as Config from "effect/Config";
4
4
  import * as Effect from "effect/Effect";
5
+ import * as Option from "effect/Option";
5
6
  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";
7
+ import { CliConfig, CliError, Command, Flag, GlobalFlag } from "effect/cli";
8
+ import * as FetchHttpClient from "effect/http/FetchHttpClient";
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,41 @@ 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(
42
+ "Compare HEAD with this Git ref; defaults to the PR base in Actions",
43
+ ),
44
+ Flag.optional,
45
+ ),
46
+ all: Flag.Boolean("all").pipe(
47
+ Flag.withDefault(false),
48
+ Flag.withDescription(
49
+ "Pack every configured package regardless of changes",
50
+ ),
51
+ ),
52
+ rebuildAllPaths: Flag.String("rebuild-all-path").pipe(
53
+ Flag.withDescription(
54
+ "Additional exact path or directory/** forcing all packages (repeatable)",
55
+ ),
56
+ Flag.atLeast(0),
57
+ ),
58
+ },
59
+ ({ group, registry, out, since, all, rebuildAllPaths }) =>
43
60
  Effect.gen(function* () {
44
61
  const cwd = yield* Effect.sync(() => process.cwd());
45
- yield* pack({ cwd, groups: group, registry, out });
62
+ yield* pack({
63
+ cwd,
64
+ groups: group,
65
+ registry,
66
+ out,
67
+ since: Option.getOrUndefined(since),
68
+ all,
69
+ rebuildAllPaths,
70
+ });
46
71
  }),
47
72
  ).pipe(
48
73
  Command.withDescription(
package/src/cli/pack.ts CHANGED
@@ -8,7 +8,7 @@ import * as Path from "effect/Path";
8
8
  import * as Schema from "effect/Schema";
9
9
  import * as SchemaIssue from "effect/SchemaIssue";
10
10
  import * as SchemaTransformation from "effect/SchemaTransformation";
11
- import * as ChildProcess from "effect/unstable/process/ChildProcess";
11
+ import * as ChildProcess from "effect/process/ChildProcess";
12
12
  import { exec } from "alchemy/Util/exec";
13
13
  import { sha256 } from "alchemy/Util/sha256";
14
14
  import { packTar, unpackTar, type TarHeader } from "modern-tar";
@@ -140,6 +140,73 @@ export interface WorkspacePackage {
140
140
  readonly group: string;
141
141
  }
142
142
 
143
+ /** Shared inputs that invalidate every configured package. */
144
+ export const REBUILD_ALL_PATHS = [
145
+ "package.json",
146
+ "pnpm-lock.yaml",
147
+ "pnpm-workspace.yaml",
148
+ "package-lock.json",
149
+ "yarn.lock",
150
+ "bun.lock",
151
+ "bun.lockb",
152
+ "tsconfig.json",
153
+ "tsconfig.base.json",
154
+ "turbo.json",
155
+ ".github/workflows/pkg.yml",
156
+ ".github/workflows/pr-package.yml",
157
+ ];
158
+
159
+ /** Select dependents first, then dependencies, without pulling in unrelated siblings. */
160
+ export const selectPackages = (
161
+ packages: ReadonlyArray<WorkspacePackage>,
162
+ dependencies: ReadonlyMap<string, ReadonlySet<string>>,
163
+ changedFiles: ReadonlyArray<string>,
164
+ rebuildAllPaths: ReadonlyArray<string> = [],
165
+ ) => {
166
+ if (
167
+ changedFiles.some((file) =>
168
+ [...REBUILD_ALL_PATHS, ...rebuildAllPaths].some((pattern) =>
169
+ pattern.endsWith("/**")
170
+ ? file === pattern.slice(0, -3) ||
171
+ file.startsWith(pattern.slice(0, -2))
172
+ : file === pattern,
173
+ ),
174
+ )
175
+ )
176
+ return [...packages];
177
+
178
+ const selected = new Set(
179
+ packages
180
+ .filter((pkg) =>
181
+ changedFiles.some(
182
+ (file) =>
183
+ file === pkg.dir ||
184
+ file.startsWith(`${pkg.dir}/`) ||
185
+ pkg.dir.startsWith(`${file}/`),
186
+ ),
187
+ )
188
+ .map((pkg) => pkg.name),
189
+ );
190
+ // A changed gitlink selects the packages nested inside that submodule.
191
+ let size: number;
192
+ do {
193
+ size = selected.size;
194
+ for (const [name, deps] of dependencies) {
195
+ if ([...deps].some((dep) => selected.has(dep))) selected.add(name);
196
+ }
197
+ } while (selected.size !== size);
198
+ const pending = [...selected];
199
+ for (const name of pending) {
200
+ for (const dep of dependencies.get(name) ?? []) {
201
+ if (!selected.has(dep)) {
202
+ selected.add(dep);
203
+ pending.push(dep);
204
+ }
205
+ }
206
+ }
207
+ return packages.filter((pkg) => selected.has(pkg.name));
208
+ };
209
+
143
210
  /**
144
211
  * Expand one level of `{a,b,c}` alternatives into plain patterns, so
145
212
  * `./packages/{alchemy,pkg}` lists exactly those two directories.
@@ -418,18 +485,21 @@ export const packPackage = Effect.fn("packPackage")(function* (options: {
418
485
  const PullRequestEvent = Schema.fromJsonString(
419
486
  Schema.Struct({
420
487
  pull_request: Schema.optionalKey(
421
- Schema.Struct({ head: Schema.Struct({ sha: Schema.String }) }),
488
+ Schema.Struct({
489
+ head: Schema.Struct({ sha: Schema.String }),
490
+ base: Schema.Struct({ sha: Schema.String }),
491
+ }),
422
492
  ),
423
493
  }),
424
494
  );
425
495
 
426
496
  /**
427
- * The pull request head commit when running under a GitHub Actions
428
- * `pull_request` event, read from the event payload. `undefined` elsewhere.
497
+ * Pull request metadata from the GitHub Actions event payload.
498
+ * `undefined` outside pull request events.
429
499
  * On that event the default checkout is a synthetic merge commit, which the
430
500
  * registry would reject because it does not match the run's head.
431
501
  */
432
- const pullRequestHead = Effect.gen(function* () {
502
+ const pullRequest = Effect.gen(function* () {
433
503
  const event = yield* Config.option(Config.String("GITHUB_EVENT_NAME"));
434
504
  const eventPath = yield* Config.option(Config.String("GITHUB_EVENT_PATH"));
435
505
  if (
@@ -442,7 +512,7 @@ const pullRequestHead = Effect.gen(function* () {
442
512
  const payload = yield* fs
443
513
  .readFileString(eventPath.value)
444
514
  .pipe(Effect.flatMap(Schema.decodeUnknownEffect(PullRequestEvent)));
445
- return payload.pull_request?.head.sha;
515
+ return payload.pull_request;
446
516
  });
447
517
 
448
518
  export interface PackOptions {
@@ -450,6 +520,9 @@ export interface PackOptions {
450
520
  readonly groups: ReadonlyArray<Group>;
451
521
  readonly registry: string;
452
522
  readonly out: string;
523
+ readonly since?: string;
524
+ readonly all?: boolean;
525
+ readonly rebuildAllPaths?: ReadonlyArray<string>;
453
526
  }
454
527
 
455
528
  /**
@@ -470,7 +543,8 @@ export const pack = Effect.fn("pack")(function* (options: PackOptions) {
470
543
 
471
544
  const root = yield* toplevel(options.cwd);
472
545
  const head = yield* gitHead(root);
473
- const prHead = yield* pullRequestHead;
546
+ const pr = yield* pullRequest;
547
+ const prHead = pr?.head.sha;
474
548
  if (prHead !== undefined && prHead !== head) {
475
549
  return yield* new WorkspaceError({
476
550
  message:
@@ -478,11 +552,7 @@ export const pack = Effect.fn("pack")(function* (options: PackOptions) {
478
552
  "Check out github.event.pull_request.head.sha before packing so tarballs are addressed by a commit that exists on the pull request.",
479
553
  });
480
554
  }
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
- }
555
+ let packages = yield* discover(options.cwd, options.groups);
486
556
 
487
557
  // Dependencies between packed packages are rewritten to the dependency's
488
558
  // immutable tarball URL, so a package is packed only after everything it
@@ -506,6 +576,38 @@ export const pack = Effect.fn("pack")(function* (options: PackOptions) {
506
576
  ),
507
577
  );
508
578
  }
579
+ const since = options.all ? undefined : (options.since ?? pr?.base.sha);
580
+ if (since !== undefined) {
581
+ // Disable rename detection so moves affect both the old and new packages.
582
+ const changed = (yield* git(root, [
583
+ "diff",
584
+ "--name-only",
585
+ "--no-renames",
586
+ "-z",
587
+ since,
588
+ head,
589
+ "--",
590
+ ]))
591
+ .split("\0")
592
+ .filter(Boolean);
593
+ const selected = selectPackages(
594
+ packages.map((pkg) => ({
595
+ ...pkg,
596
+ dir: path.relative(root, pkg.absDir).split(path.sep).join("/"),
597
+ })),
598
+ dependencies,
599
+ changed,
600
+ options.rebuildAllPaths,
601
+ );
602
+ const names = new Set(selected.map((pkg) => pkg.name));
603
+ packages = packages.filter((pkg) => names.has(pkg.name));
604
+ for (const name of dependencies.keys()) {
605
+ if (!names.has(name)) dependencies.delete(name);
606
+ }
607
+ yield* Console.log(
608
+ `Selected ${packages.length} package(s) changed since ${since}, including dependents and dependencies.`,
609
+ );
610
+ }
509
611
  const levels = yield* dependencyLevels(dependencies);
510
612
 
511
613
  // The output directory is replaced wholesale, so it has to be a proper
@@ -594,9 +696,13 @@ export const pack = Effect.fn("pack")(function* (options: PackOptions) {
594
696
  const artifact = manifestArtifactName(yield* sha256(manifestText));
595
697
  const stepOutput = yield* Config.option(Config.String("GITHUB_OUTPUT"));
596
698
  if (Option.isSome(stepOutput)) {
597
- yield* fs.writeFileString(stepOutput.value, `artifact-name=${artifact}\n`, {
598
- flag: "a",
599
- });
699
+ yield* fs.writeFileString(
700
+ stepOutput.value,
701
+ `artifact-name=${artifact}\npackage-count=${entries.length}\n`,
702
+ {
703
+ flag: "a",
704
+ },
705
+ );
600
706
  }
601
707
  yield* Console.log(`Manifest artifact name: ${artifact}`);
602
708
  return manifest;
@@ -5,7 +5,7 @@ import * as Effect from "effect/Effect";
5
5
  import * as FileSystem from "effect/FileSystem";
6
6
  import * as Path from "effect/Path";
7
7
  import * as Schema from "effect/Schema";
8
- import * as HttpApiClient from "effect/unstable/httpapi/HttpApiClient";
8
+ import * as HttpApiClient from "effect/http-api/HttpApiClient";
9
9
  import { MANIFEST_FILE, ManifestJson, type Manifest } from "../Manifest.ts";
10
10
  import { PkgApi, type TarballRef } from "../Protocol.ts";
11
11
 
@@ -71,6 +71,11 @@ export const publish = Effect.fn("publish")(function* (
71
71
  });
72
72
  }
73
73
 
74
+ if (manifest.packages.length === 0) {
75
+ yield* Console.log("No affected packages to publish.");
76
+ return { packages: [] };
77
+ }
78
+
74
79
  const upload = (pkg: Manifest["packages"][number]) =>
75
80
  Effect.gen(function* () {
76
81
  const bytes = yield* fs.readFile(path.join(dir, pkg.file));