@mannyc1/ts-release 0.2.2 → 0.3.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.
Files changed (107) hide show
  1. package/ARCHITECTURE.md +60 -0
  2. package/CHANGELOG.md +82 -0
  3. package/README.md +129 -6
  4. package/SPEC.md +51 -0
  5. package/apps/release-ts/README.md +7 -0
  6. package/apps/ts-release-agents/package.json +1 -1
  7. package/apps/ts-release-agents/src/check.ts +7 -2
  8. package/apps/ts-release-agents/test/contract.test.ts +2 -2
  9. package/dist/bin/ts-release.js +154 -47
  10. package/dist/drivers/archive.d.ts +2 -1
  11. package/dist/drivers/archive.d.ts.map +1 -1
  12. package/dist/drivers/archive.js +12 -8
  13. package/dist/drivers/archive.js.map +1 -1
  14. package/dist/drivers/process.d.ts.map +1 -1
  15. package/dist/drivers/process.js +4 -4
  16. package/dist/drivers/process.js.map +1 -1
  17. package/dist/drivers/python-wheel.d.ts +21 -0
  18. package/dist/drivers/python-wheel.d.ts.map +1 -0
  19. package/dist/drivers/python-wheel.js +109 -0
  20. package/dist/drivers/python-wheel.js.map +1 -0
  21. package/dist/drivers/workspace.d.ts +3 -0
  22. package/dist/drivers/workspace.d.ts.map +1 -1
  23. package/dist/drivers/workspace.js +5 -1
  24. package/dist/drivers/workspace.js.map +1 -1
  25. package/dist/operation-journal/authority.d.ts +3 -0
  26. package/dist/operation-journal/authority.d.ts.map +1 -0
  27. package/dist/operation-journal/authority.js +147 -0
  28. package/dist/operation-journal/authority.js.map +1 -0
  29. package/dist/operation-journal/aws/deadline.d.ts +12 -0
  30. package/dist/operation-journal/aws/deadline.d.ts.map +1 -0
  31. package/dist/operation-journal/aws/deadline.js +38 -0
  32. package/dist/operation-journal/aws/deadline.js.map +1 -0
  33. package/dist/operation-journal/aws/oidc.d.ts +27 -0
  34. package/dist/operation-journal/aws/oidc.d.ts.map +1 -0
  35. package/dist/operation-journal/aws/oidc.js +361 -0
  36. package/dist/operation-journal/aws/oidc.js.map +1 -0
  37. package/dist/operation-journal/aws/policy.d.ts +42 -0
  38. package/dist/operation-journal/aws/policy.d.ts.map +1 -0
  39. package/dist/operation-journal/aws/policy.js +404 -0
  40. package/dist/operation-journal/aws/policy.js.map +1 -0
  41. package/dist/operation-journal/aws/s3-boundary.d.ts +17 -0
  42. package/dist/operation-journal/aws/s3-boundary.d.ts.map +1 -0
  43. package/dist/operation-journal/aws/s3-boundary.js +417 -0
  44. package/dist/operation-journal/aws/s3-boundary.js.map +1 -0
  45. package/dist/operation-journal/aws.d.ts +11 -0
  46. package/dist/operation-journal/aws.d.ts.map +1 -0
  47. package/dist/operation-journal/aws.js +50 -0
  48. package/dist/operation-journal/aws.js.map +1 -0
  49. package/dist/operation-journal/canonical.d.ts +52 -0
  50. package/dist/operation-journal/canonical.d.ts.map +1 -0
  51. package/dist/operation-journal/canonical.js +282 -0
  52. package/dist/operation-journal/canonical.js.map +1 -0
  53. package/dist/operation-journal/model.d.ts +251 -0
  54. package/dist/operation-journal/model.d.ts.map +1 -0
  55. package/dist/operation-journal/model.js +56 -0
  56. package/dist/operation-journal/model.js.map +1 -0
  57. package/dist/operation-journal/reducer.d.ts +28 -0
  58. package/dist/operation-journal/reducer.d.ts.map +1 -0
  59. package/dist/operation-journal/reducer.js +73 -0
  60. package/dist/operation-journal/reducer.js.map +1 -0
  61. package/dist/operation-journal/s3.d.ts +7 -0
  62. package/dist/operation-journal/s3.d.ts.map +1 -0
  63. package/dist/operation-journal/s3.js +667 -0
  64. package/dist/operation-journal/s3.js.map +1 -0
  65. package/dist/operation-journal.d.ts +14 -0
  66. package/dist/operation-journal.d.ts.map +1 -0
  67. package/dist/operation-journal.js +12 -0
  68. package/dist/operation-journal.js.map +1 -0
  69. package/dist/platform/credentials.d.ts.map +1 -1
  70. package/dist/platform/credentials.js +65 -3
  71. package/dist/platform/credentials.js.map +1 -1
  72. package/dist/publication/github.d.ts.map +1 -1
  73. package/dist/publication/github.js +66 -15
  74. package/dist/publication/github.js.map +1 -1
  75. package/dist/release/capabilities.d.ts.map +1 -1
  76. package/dist/release/capabilities.js +2 -2
  77. package/dist/release/capabilities.js.map +1 -1
  78. package/dist/release/prepare.d.ts.map +1 -1
  79. package/dist/release/prepare.js +4 -5
  80. package/dist/release/prepare.js.map +1 -1
  81. package/dist/release/prepared-store.d.ts.map +1 -1
  82. package/dist/release/prepared-store.js +11 -9
  83. package/dist/release/prepared-store.js.map +1 -1
  84. package/package.json +19 -4
  85. package/src/drivers/archive.ts +17 -8
  86. package/src/drivers/process.ts +3 -4
  87. package/src/drivers/python-wheel.ts +145 -0
  88. package/src/drivers/workspace.ts +7 -1
  89. package/src/operation-journal/authority.ts +162 -0
  90. package/src/operation-journal/aws/deadline.ts +41 -0
  91. package/src/operation-journal/aws/oidc.ts +439 -0
  92. package/src/operation-journal/aws/policy.ts +491 -0
  93. package/src/operation-journal/aws/s3-boundary.ts +562 -0
  94. package/src/operation-journal/aws.ts +60 -0
  95. package/src/operation-journal/canonical.ts +373 -0
  96. package/src/operation-journal/model.ts +301 -0
  97. package/src/operation-journal/reducer.ts +112 -0
  98. package/src/operation-journal/s3.ts +884 -0
  99. package/src/operation-journal.ts +51 -0
  100. package/src/platform/credentials.ts +71 -3
  101. package/src/publication/github.ts +83 -15
  102. package/src/release/capabilities.ts +3 -2
  103. package/src/release/prepare.ts +3 -5
  104. package/src/release/prepared-store.ts +8 -7
  105. package/templates/README.md +2 -2
  106. package/templates/github-actions/release.yml +24 -7
  107. package/templates/github-actions/reviewed-release.yml +49 -14
@@ -0,0 +1,51 @@
1
+ /**
2
+ * Provider-neutral durable operation journal.
3
+ *
4
+ * Consumers own payload codecs and provider semantics. This subpath owns only
5
+ * the canonical envelope, finite transition machine, S3 conditional commit,
6
+ * durable acknowledgment, and exact-chain replay.
7
+ */
8
+ export {
9
+ JournalAuthorityMismatch,
10
+ JournalConflict,
11
+ JournalInputError,
12
+ JournalIntegrityError,
13
+ JournalStorageOutcomeUnknown,
14
+ JournalStorageUnavailable,
15
+ JournalTransitionError,
16
+ S3JournalBoundaryError,
17
+ journalRecordTags,
18
+ operationJournalByteLimits
19
+ } from "./operation-journal/model.js"
20
+ export type {
21
+ CanonicalOperationJournalShape,
22
+ JournalAcknowledgement,
23
+ JournalAppendError,
24
+ JournalAppendRequest,
25
+ JournalOperation,
26
+ JournalReadError,
27
+ JournalRecord,
28
+ JournalRecordTag,
29
+ JournalSnapshot,
30
+ JournalStateTag,
31
+ JournalWorkflowCoordinate,
32
+ S3JournalAuthority,
33
+ S3JournalBoundaryShape,
34
+ S3JournalDeleteMarker,
35
+ S3JournalNamespaceListing,
36
+ S3JournalObjectVersion,
37
+ S3JournalOidcClaims,
38
+ S3JournalOidcTrustConditions,
39
+ S3JournalPutCondition,
40
+ S3JournalPutRequest,
41
+ S3JournalPutResult
42
+ } from "./operation-journal/model.js"
43
+ export {
44
+ deriveOperationKey,
45
+ journalEventKey,
46
+ journalHeadKey,
47
+ journalNamespace
48
+ } from "./operation-journal/canonical.js"
49
+ export { admitJournalTransition } from "./operation-journal/reducer.js"
50
+ export { makeS3CanonicalOperationJournal } from "./operation-journal/s3.js"
51
+ export type { S3CanonicalOperationJournalOptions } from "./operation-journal/s3.js"
@@ -1,5 +1,5 @@
1
1
  import { Buffer } from "node:buffer"
2
- import { chmod, copyFile, mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"
2
+ import { chmod, copyFile, mkdir, mkdtemp, readFile, realpath, rm, stat, writeFile } from "node:fs/promises"
3
3
  import { tmpdir } from "node:os"
4
4
  import { basename, isAbsolute, join, resolve } from "node:path"
5
5
  import * as Config from "effect/Config"
@@ -588,6 +588,7 @@ const makeAuthorizedMutationHttp = (
588
588
  type NpmUserConfigMetadata = {
589
589
  readonly directory: string
590
590
  readonly path: string
591
+ readonly globalConfigPath: string
591
592
  readonly subject: SubjectId
592
593
  readonly grant: ScopedSecret
593
594
  readonly token: Redacted.Redacted<string>
@@ -620,9 +621,18 @@ const makeNpmUserConfigResource = (
620
621
  const directory = await mkdtemp(join(root, "ts-release-npm-"))
621
622
  try {
622
623
  const path = join(directory, "userconfig")
624
+ const globalConfigPath = join(directory, "globalconfig")
623
625
  const contents = `${npmAuthLine(input.registryUrl, token)}\nignore-scripts=true\n`
624
626
  await writeFile(path, contents, { mode: 0o600, flag: "wx" })
625
- npmUserConfigs.set(handle, { directory, path, subject: input.operation.subject, grant, token })
627
+ await writeFile(globalConfigPath, "", { mode: 0o600, flag: "wx" })
628
+ npmUserConfigs.set(handle, {
629
+ directory,
630
+ path,
631
+ globalConfigPath,
632
+ subject: input.operation.subject,
633
+ grant,
634
+ token
635
+ })
626
636
  return handle
627
637
  } catch (cause) {
628
638
  await rm(directory, { recursive: true, force: true })
@@ -671,6 +681,58 @@ const optionalPath = Config.option(Config.string("PATH")).pipe(
671
681
  const closedBaseEnvironment = (path: string | undefined): Record<string, string> =>
672
682
  path === undefined ? {} : { PATH: path }
673
683
 
684
+ const optionalReleaseHome = Config.option(Config.string("TS_RELEASE_HOME")).pipe(
685
+ Effect.map(Option.getOrUndefined),
686
+ Effect.orElseSucceed(() => undefined)
687
+ )
688
+
689
+ const privateTrustedNpmEnvironment = Effect.fn("CertifiedPublisherSpawn.privateTrustedNpmEnvironment")(
690
+ function*(grant: WorkloadIdentity) {
691
+ const configured = yield* optionalReleaseHome
692
+ if (configured === undefined || !isAbsolute(configured) || resolve(configured) !== configured) {
693
+ return yield* new CredentialUnavailable({
694
+ subject: grant.subject,
695
+ provider: grant.provider,
696
+ purpose: "publish",
697
+ reason: "Trusted npm publishing requires one canonical absolute TS_RELEASE_HOME."
698
+ })
699
+ }
700
+ return yield* Effect.tryPromise({
701
+ try: async () => {
702
+ const canonicalHome = await realpath(configured)
703
+ if (canonicalHome !== configured) throw new Error("TS_RELEASE_HOME is not canonical.")
704
+ const homeMetadata = await stat(canonicalHome)
705
+ const owner = typeof process.getuid === "function" ? process.getuid() : undefined
706
+ if (!homeMetadata.isDirectory() || (homeMetadata.mode & 0o077) !== 0 ||
707
+ (owner !== undefined && homeMetadata.uid !== owner)) {
708
+ throw new Error("TS_RELEASE_HOME is not one private caller-owned directory.")
709
+ }
710
+ const userConfig = join(canonicalHome, "npm-userconfig")
711
+ const globalConfig = join(canonicalHome, "npm-globalconfig")
712
+ for (const path of [userConfig, globalConfig]) {
713
+ if (await realpath(path) !== path) throw new Error("A trusted npm config path is not canonical.")
714
+ const metadata = await stat(path)
715
+ if (!metadata.isFile() || (metadata.mode & 0o777) !== 0o600 ||
716
+ (owner !== undefined && metadata.uid !== owner) || (await readFile(path)).length !== 0) {
717
+ throw new Error("A trusted npm config is not an empty private caller-owned file.")
718
+ }
719
+ }
720
+ return {
721
+ HOME: canonicalHome,
722
+ NPM_CONFIG_USERCONFIG: userConfig,
723
+ NPM_CONFIG_GLOBALCONFIG: globalConfig
724
+ }
725
+ },
726
+ catch: () => new CredentialUnavailable({
727
+ subject: grant.subject,
728
+ provider: grant.provider,
729
+ purpose: "publish",
730
+ reason: "Trusted npm publishing requires validated empty private npm configuration files."
731
+ })
732
+ })
733
+ }
734
+ )
735
+
674
736
  const workloadEnvironment = Effect.fn("CertifiedPublisherSpawn.workloadEnvironment")(function*(
675
737
  trustedWorkloads: TrustedWorkloadVault,
676
738
  grant: WorkloadIdentity
@@ -693,7 +755,11 @@ const workloadEnvironment = Effect.fn("CertifiedPublisherSpawn.workloadEnvironme
693
755
  })
694
756
  }
695
757
  const path = yield* optionalPath
696
- const env = closedBaseEnvironment(path)
758
+ const privateEnvironment = yield* privateTrustedNpmEnvironment(grant)
759
+ const env: Record<string, string> = {
760
+ ...closedBaseEnvironment(path),
761
+ ...privateEnvironment
762
+ }
697
763
  for (const name of oidcNames) {
698
764
  const value = material.oidc.get(name)
699
765
  if (value === undefined) {
@@ -858,7 +924,9 @@ const publisherEnvironment = Effect.fn("CertifiedPublisherSpawn.environment")(fu
858
924
  const path = yield* optionalPath
859
925
  return {
860
926
  ...closedBaseEnvironment(path),
927
+ HOME: metadata.directory,
861
928
  NPM_CONFIG_USERCONFIG: metadata.path,
929
+ NPM_CONFIG_GLOBALCONFIG: metadata.globalConfigPath,
862
930
  NPM_CONFIG_IGNORE_SCRIPTS: "true"
863
931
  }
864
932
  }
@@ -135,6 +135,10 @@ type GithubMutationPlan =
135
135
  readonly uploadUrl: string
136
136
  readonly assetIndexes: ReadonlyArray<number>
137
137
  }
138
+ | {
139
+ readonly _tag: "PublishRelease"
140
+ readonly releaseId: number
141
+ }
138
142
 
139
143
  type PageResult =
140
144
  | { readonly _tag: "Complete", readonly assets: ReadonlyArray<ReleaseAssetFacts> }
@@ -451,7 +455,8 @@ const listAllAssets = Effect.fn("GitHubReleaseSubject.listAllAssets")(function*(
451
455
 
452
456
  const compareReleaseMetadata = (
453
457
  publication: PreparedGitHubPublication,
454
- facts: ReleaseFacts
458
+ facts: ReleaseFacts,
459
+ options: { readonly ignoreDraft?: boolean } = {}
455
460
  ): ReadonlyArray<Difference> => {
456
461
  const differences: Array<Difference> = []
457
462
  if (facts.tag !== publication.tag.toString()) {
@@ -462,7 +467,7 @@ const compareReleaseMetadata = (
462
467
  }
463
468
  const expectedBody = publication.body ?? ""
464
469
  if (facts.body !== expectedBody) differences.push(textDifference("release.body", expectedBody, facts.body))
465
- if (facts.draft !== publication.draft) {
470
+ if (options.ignoreDraft !== true && facts.draft !== publication.draft) {
466
471
  differences.push(scalarDifference("release.draft", publication.draft, facts.draft))
467
472
  }
468
473
  if (facts.prerelease !== publication.prerelease) {
@@ -521,6 +526,14 @@ const decideFor = (
521
526
  })
522
527
  })
523
528
  }
529
+ if (plan?._tag === "PublishRelease") {
530
+ return NeedsMutation.make({
531
+ subject: publication.authority.subject,
532
+ precondition: MutationPrecondition.make({
533
+ kind: NonEmptyName.make("github-exact-draft-assets-complete")
534
+ })
535
+ })
536
+ }
524
537
  return ProviderBlocked.make({
525
538
  subject: publication.authority.subject,
526
539
  reason: SafeReason.make("GitHub absence carried no exact mutation plan.")
@@ -557,7 +570,9 @@ const appliedAttempt = (publication: PreparedGitHubPublication): Applied => Appl
557
570
  subject: publication.authority.subject,
558
571
  fact: ProviderMutationFact.make({
559
572
  subject: publication.authority.subject,
560
- detail: SafeReason.make("GitHub accepted the release mutation and every planned asset upload.")
573
+ detail: SafeReason.make(
574
+ "GitHub accepted every mutation dispatched in this convergence phase; final equivalence still requires provider reobservation."
575
+ )
561
576
  })
562
577
  })
563
578
 
@@ -720,7 +735,10 @@ export const makeGithubSubjects = (
720
735
  if (release === undefined || !validateUploadUrl(publication, release.id, release.uploadUrl)) {
721
736
  return inconclusive(publication, "The GitHub release response omitted identity facts or carried an invalid repository-scoped upload template.")
722
737
  }
723
- const releaseDifferences = compareReleaseMetadata(publication, release)
738
+ const stagedForPublicPromotion = publication.draft === false && release.draft === true
739
+ const releaseDifferences = compareReleaseMetadata(publication, release, {
740
+ ignoreDraft: stagedForPublicPromotion
741
+ })
724
742
  if (releaseDifferences.length > 0) return presentDifferent(publication, releaseDifferences)
725
743
 
726
744
  const listed = yield* listAllAssets(publication, http, grant, release.id)
@@ -799,6 +817,11 @@ export const makeGithubSubjects = (
799
817
  }
800
818
  }
801
819
  if (missingIndexes.length > 0) {
820
+ if (!release.draft) {
821
+ return presentDifferent(publication, missingIndexes.map((index) => scalarDifference(
822
+ `asset.${publication.assets[index]!.name}.count`, 1, 0
823
+ )))
824
+ }
802
825
  if (mutationMayStillBecomeVisible) {
803
826
  return pending(publication, "One or more intended GitHub assets are not yet visible after the accepted mutation.")
804
827
  }
@@ -814,6 +837,14 @@ export const makeGithubSubjects = (
814
837
  "A full paginated release-asset enumeration proved at least one intended asset name absent."
815
838
  )
816
839
  }
840
+ if (stagedForPublicPromotion) {
841
+ mutationPlan = { _tag: "PublishRelease", releaseId: release.id }
842
+ return absent(
843
+ publication,
844
+ "github-exact-draft-assets-complete",
845
+ "The exact private draft and complete asset set are ready for one final public transition."
846
+ )
847
+ }
817
848
  return PresentEquivalent.make({ subject: publication.authority.subject })
818
849
  })
819
850
 
@@ -837,14 +868,19 @@ export const makeGithubSubjects = (
837
868
  decision.proof.kind === "github-authenticated-repository-and-unique-tag"
838
869
  : plan._tag === "CreateRelease"
839
870
  ? decision._tag === "NeedsMutation" && decision.precondition.kind === "github-release-absent-at-exact-tag"
840
- : decision._tag === "NeedsMutation" &&
871
+ : plan._tag === "UploadAssets"
872
+ ? decision._tag === "NeedsMutation" &&
841
873
  decision.precondition.kind === "github-intended-assets-authoritatively-absent"
874
+ : decision._tag === "NeedsMutation" &&
875
+ decision.precondition.kind === "github-exact-draft-assets-complete"
842
876
  if (!expectedDecision) {
843
877
  return yield* mutationFailure(publication, "The GitHub mutation decision does not match its observed precondition.")
844
878
  }
845
879
  const indexes = plan._tag === "CreateRelease"
846
880
  ? publication.assets.map((_, index) => index)
847
- : [...plan.assetIndexes]
881
+ : plan._tag === "UploadAssets"
882
+ ? [...plan.assetIndexes]
883
+ : []
848
884
  const bytesByIndex = new Map<number, Uint8Array>()
849
885
  for (const index of indexes) {
850
886
  const asset = publication.assets[index]
@@ -866,7 +902,10 @@ export const makeGithubSubjects = (
866
902
  decision
867
903
  }
868
904
  let acceptedWrites = 0
869
- const dispatch = Effect.fn("GitHubReleaseSubject.dispatch")(function*(request: MutationHttpRequest) {
905
+ const dispatch = Effect.fn("GitHubReleaseSubject.dispatch")(function*(
906
+ request: MutationHttpRequest,
907
+ acceptedStatus: 200 | 201
908
+ ) {
870
909
  const exchange = yield* executeMutation(mutationHttp, operation, request, grant)
871
910
  if (exchange._tag === "Failure") {
872
911
  if (acceptedWrites > 0 || exchange.error.commitment === "unknown") {
@@ -881,7 +920,7 @@ export const makeGithubSubjects = (
881
920
  "before-dispatch"
882
921
  )
883
922
  }
884
- if (exchange.response.status === 201) {
923
+ if (exchange.response.status === acceptedStatus) {
885
924
  acceptedWrites += 1
886
925
  return { _tag: "Accepted", response: exchange.response } as const
887
926
  }
@@ -891,14 +930,15 @@ export const makeGithubSubjects = (
891
930
  ) } as const
892
931
  })
893
932
 
894
- let uploadUrl: string
933
+ let releaseId: number
934
+ let uploadUrl: string | undefined
895
935
  if (plan._tag === "CreateRelease") {
896
936
  const body = JSON.stringify({
897
937
  tag_name: publication.tag.toString(),
898
938
  target_commitish: publication.targetCommit.toString(),
899
939
  name: publication.title.toString(),
900
940
  body: publication.body ?? "",
901
- draft: publication.draft,
941
+ draft: true,
902
942
  prerelease: publication.prerelease
903
943
  })
904
944
  const created = yield* dispatch({
@@ -910,23 +950,28 @@ export const makeGithubSubjects = (
910
950
  "content-length": String(new TextEncoder().encode(body).length)
911
951
  },
912
952
  body
913
- })
953
+ }, 201)
914
954
  if (created._tag === "Attempt") return created.attempt
915
955
  const facts = releaseFacts(parseJson(created.response.body))
916
- if (facts === undefined || !validateUploadUrl(publication, facts.id, facts.uploadUrl) ||
917
- compareReleaseMetadata(publication, facts).length > 0) {
956
+ if (facts === undefined || facts.draft !== true ||
957
+ !validateUploadUrl(publication, facts.id, facts.uploadUrl) ||
958
+ compareReleaseMetadata(publication, facts, { ignoreDraft: publication.draft === false }).length > 0) {
918
959
  return unknownAttempt(
919
960
  publication,
920
961
  "GitHub accepted release creation but its response did not establish the exact release identity and repository-scoped upload template."
921
962
  )
922
963
  }
964
+ releaseId = facts.id
923
965
  uploadUrl = facts.uploadUrl
924
- } else {
966
+ } else if (plan._tag === "UploadAssets") {
967
+ releaseId = plan.releaseId
925
968
  uploadUrl = plan.uploadUrl
969
+ } else {
970
+ releaseId = plan.releaseId
926
971
  }
927
972
  for (const index of indexes) {
928
973
  const bytes = bytesByIndex.get(index)!
929
- const uploaded = yield* dispatch(uploadRequest(publication, uploadUrl, index, bytes))
974
+ const uploaded = yield* dispatch(uploadRequest(publication, uploadUrl!, index, bytes), 201)
930
975
  if (uploaded._tag === "Attempt") return uploaded.attempt
931
976
  const intended = publication.assets[index]!
932
977
  const facts = releaseAssetFacts(publication, parseJson(uploaded.response.body))
@@ -939,6 +984,29 @@ export const makeGithubSubjects = (
939
984
  )
940
985
  }
941
986
  }
987
+ if (plan._tag === "PublishRelease") {
988
+ const body = JSON.stringify({ draft: false })
989
+ const published = yield* dispatch({
990
+ method: "PATCH",
991
+ url: `${publication.authority.audience}/releases/${releaseId}`,
992
+ headers: {
993
+ ...githubJsonHeaders,
994
+ "content-type": "application/json",
995
+ "content-length": String(new TextEncoder().encode(body).length)
996
+ },
997
+ body
998
+ }, 200)
999
+ if (published._tag === "Attempt") return published.attempt
1000
+ const facts = releaseFacts(parseJson(published.response.body))
1001
+ if (facts === undefined || facts.id !== releaseId || facts.draft !== false ||
1002
+ !validateUploadUrl(publication, releaseId, facts.uploadUrl) ||
1003
+ compareReleaseMetadata(publication, facts).length > 0) {
1004
+ return unknownAttempt(
1005
+ publication,
1006
+ "GitHub accepted final release publication but its response did not establish the exact public release identity."
1007
+ )
1008
+ }
1009
+ }
942
1010
  return appliedAttempt(publication)
943
1011
  })
944
1012
 
@@ -113,8 +113,9 @@ export const contributeSourceArtifacts = (
113
113
  id, argv: preparation.run,
114
114
  cwd, inputs,
115
115
  outputs: [
116
- output(preparation.outputs[0]!.id, preparation.outputs[0]!.path, preparation.outputs[0]!.kind ?? "file", preparation.outputs[0]!.mediaType),
117
- ...preparation.outputs.slice(1).map((item) => output(item.id, item.path, item.kind ?? "file", item.mediaType))
116
+ output(preparation.outputs[0]!.id, render(preparation.outputs[0]!.path, config), preparation.outputs[0]!.kind ?? "file", preparation.outputs[0]!.mediaType),
117
+ ...preparation.outputs.slice(1).map((item) =>
118
+ output(item.id, render(item.path, config), item.kind ?? "file", item.mediaType))
118
119
  ]
119
120
  }))
120
121
  else preparations.push(GraphCommandCollection.make({
@@ -5,16 +5,14 @@ import {
5
5
  existsSync,
6
6
  lstatSync,
7
7
  mkdirSync,
8
- mkdtempSync,
9
8
  readFileSync,
10
9
  readdirSync,
11
10
  realpathSync,
12
11
  rmSync
13
12
  } from "node:fs"
14
13
  import { basename, isAbsolute, join, relative, resolve, sep } from "node:path"
15
- import { tmpdir } from "node:os"
16
14
  import { tarGz, zip, type ArchiveEntry } from "../drivers/archive.js"
17
- import { secureRead, secureWrite } from "../drivers/workspace.js"
15
+ import { makeCanonicalTemporaryDirectory, secureRead, secureWrite } from "../drivers/workspace.js"
18
16
  import type {
19
17
  BunCompileRuntimeIdentity,
20
18
  CommandOutcome,
@@ -956,12 +954,12 @@ const runtimeIdentity = (
956
954
  : encodeCanonicalJson(bunCompileRuntimes),
957
955
  npmPack: npmPack === undefined ? "not-used" : encodeCanonicalJson(npmPack),
958
956
  releaseGraph,
959
- preparer: "@mannyc1/ts-release@0.2.2"
957
+ preparer: "@mannyc1/ts-release@0.3.0"
960
958
  })
961
959
 
962
960
  export const prepareRelease = Effect.fn("prepareRelease")(function*(input: PreparationRequest) {
963
961
  const sourceRoot = input.context.workspace.toString()
964
- const root = yield* attempt(() => mkdtempSync(join(tmpdir(), "ts-release-prepare-")))
962
+ const root = yield* attempt(() => makeCanonicalTemporaryDirectory("ts-release-prepare-"))
965
963
  try {
966
964
  const sourceSnapshot = yield* input.materializeSource(input.context, WorkspaceRoot.make(root)).pipe(Effect.mapError(failure))
967
965
  const context = stagedContext(input.context, root)
@@ -161,8 +161,9 @@ const causeReason = (cause: unknown): string =>
161
161
  : String(cause)
162
162
  const canonicalDirectory = (directory: string): string => {
163
163
  if (!existsSync(directory)) mkdirSync(directory, { recursive: true, mode: 0o700 })
164
+ if (lstatSync(directory).isSymbolicLink()) fail("Prepared store root must not be a symlink.")
164
165
  const real = realpathSync(directory)
165
- if (real !== directory) fail("Prepared store root must not be a symlink.")
166
+ if (!lstatSync(real).isDirectory()) fail("Prepared store root must be a directory.")
166
167
  return real
167
168
  }
168
169
  const syncDirectory = (directory: string): void => {
@@ -220,16 +221,16 @@ export interface CommittedPreparedRelease {
220
221
  }
221
222
 
222
223
  const readBundle = (directory: string): PreparedBundle => {
224
+ if (lstatSync(directory).isSymbolicLink()) fail("Prepared bundle directory must not be a symlink.")
223
225
  const real = realpathSync(directory)
224
- if (real !== directory || lstatSync(directory).isSymbolicLink()) fail("Prepared bundle directory must not be a symlink.")
225
- const bytes = secureRead(directory, "prepared-release.json").bytes
226
+ const bytes = secureRead(real, "prepared-release.json").bytes
226
227
  const manifest = decodePreparedRelease(bytes)
227
228
  if (manifest.kind !== "complete") fail("Prepared store refuses every unknown or partial bundle kind.")
228
229
  const manifestDigest = sha256Digest(bytes).hex
229
- if (basename(directory) !== manifestDigest) fail("Prepared bundle directory does not match its manifest digest.")
230
- const entries = readdirSync(directory, { withFileTypes: true })
230
+ if (basename(real) !== manifestDigest) fail("Prepared bundle directory does not match its manifest digest.")
231
+ const entries = readdirSync(real, { withFileTypes: true })
231
232
  if (entries.some((entry) => entry.name !== "prepared-release.json" && entry.name !== "blobs")) fail("Prepared bundle contains an unexpected top-level entry.")
232
- const blobDirectory = join(directory, "blobs")
233
+ const blobDirectory = join(real, "blobs")
233
234
  if (!existsSync(blobDirectory) || lstatSync(blobDirectory).isSymbolicLink() || !statSync(blobDirectory).isDirectory()) fail("Prepared bundle is missing its real blobs directory.")
234
235
  const artifacts = artifactsById(manifest)
235
236
  validatePublications(manifest, artifacts)
@@ -241,7 +242,7 @@ const readBundle = (directory: string): PreparedBundle => {
241
242
  if (actualBlobs.size !== expectedBlobs.size || [...expectedBlobs].some((blob) => !actualBlobs.has(blob))) fail("Prepared bundle blob set does not match its manifest.")
242
243
  const blobs = new Map<string, Uint8Array>()
243
244
  for (const artifact of artifacts.values()) {
244
- const blob = secureRead(directory, `blobs/${artifact.blob.hex}`).bytes
245
+ const blob = secureRead(real, `blobs/${artifact.blob.hex}`).bytes
245
246
  if (blob.length !== artifact.size || !digestEquals(sha256Digest(blob), artifact.digest)) fail(`Prepared blob ${artifact.id} failed size or digest verification.`)
246
247
  blobs.set(artifact.id.toString(), new Uint8Array(blob))
247
248
  }
@@ -29,7 +29,7 @@ its publish job fails after preparation, rerun that failed job in the same
29
29
  workflow run so `${{ needs.prepare.outputs.prepared-ref }}` continues to name
30
30
  the original bundle. Do not create a new prepare run as recovery.
31
31
 
32
- The trusted-publishing job installs the Node 22.22.2 and npm 11.5.1
32
+ The trusted-publishing job installs the Node 22.22.2 and npm 11.11.0
33
33
  publisher boundary explicitly. The reviewed prepare job has no OIDC or publish
34
34
  authority; only its publish job installs that publisher toolchain.
35
35
  Trusted configuration must name the invoking workflow and exact ref: the
@@ -49,7 +49,7 @@ explicit token-mode migration template for non-OIDC hosts; it names a
49
49
  credential reference but never contains a credential value.
50
50
 
51
51
  All public Action templates use the exact intended monorepo coordinate
52
- `mannyc2/ts-release/apps/ts-release-action@v0.2.2`. Do not switch it to a
52
+ `mannyc2/ts-release/apps/ts-release-action@v0.3.0`. Do not switch it to a
53
53
  floating branch. Candidate certification must stop unless the immutable tag
54
54
  can exist at the exact result commit before a package README naming it is
55
55
  published.
@@ -19,38 +19,55 @@ permissions:
19
19
  jobs:
20
20
  release:
21
21
  if: ${{ github.ref == 'refs/heads/main' && github.sha == inputs.candidate_sha }}
22
- runs-on: ubuntu-latest
22
+ runs-on: ubuntu-24.04
23
23
  permissions:
24
24
  actions: read
25
25
  contents: write
26
26
  id-token: write
27
27
  steps:
28
- - uses: actions/checkout@v4
28
+ - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
29
29
  with:
30
30
  persist-credentials: false
31
- - uses: actions/setup-node@v4
31
+ - id: setup-node
32
+ uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
32
33
  with:
33
34
  node-version: "22.22.2"
34
- - uses: oven-sh/setup-bun@v2
35
+ - id: setup-bun
36
+ uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
35
37
  with:
36
38
  bun-version: "1.3.14"
37
- - run: bun add --global npm@11.5.1
39
+ - id: release-tools
40
+ name: Bind exact release tool paths
41
+ env:
42
+ SETUP_BUN_PATH: ${{ steps.setup-bun.outputs.bun-path }}
43
+ run: |-
44
+ node_path="$(type -P node)"
45
+ bun_path="$(type -P bun)"
46
+ [[ "$node_path" = /* && "$bun_path" = /* ]]
47
+ [[ "$("$node_path" --version)" == v22.22.2 ]]
48
+ [[ "$("$bun_path" --version)" == 1.3.14 ]]
49
+ [[ "$(realpath "$bun_path")" == "$(realpath "$SETUP_BUN_PATH")" ]]
50
+ echo "node-bin=$node_path" >> "$GITHUB_OUTPUT"
51
+ echo "bun-bin=$bun_path" >> "$GITHUB_OUTPUT"
52
+ - run: bun add --global npm@11.11.0
38
53
  - name: Prime isolated Bun dependency cache
39
54
  if: ${{ inputs.prepared_ref == '' }}
40
55
  run: |-
41
56
  bun install --frozen-lockfile --ignore-scripts --no-save --linker=hoisted
42
57
  bun --no-env-file --no-install -e 'await (await import("node:fs/promises")).rm("node_modules", { recursive: true, force: true })'
43
58
  - id: release
44
- uses: mannyc2/ts-release/apps/ts-release-action@v0.2.2
59
+ uses: mannyc2/ts-release/apps/ts-release-action@v0.3.0
45
60
  env:
46
61
  GITHUB_TOKEN: ${{ github.token }}
62
+ TS_RELEASE_NODE_BIN: ${{ steps.release-tools.outputs.node-bin }}
63
+ TS_RELEASE_BUN_BIN: ${{ steps.release-tools.outputs.bun-bin }}
47
64
  with:
48
65
  command: ${{ inputs.prepared_ref == '' && 'release' || 'publish' }}
49
66
  config: ${{ inputs.prepared_ref == '' && 'release.config.json' || '' }}
50
67
  prepared: ${{ inputs.prepared_ref }}
51
68
  - name: Upload redacted release report
52
69
  if: ${{ always() && steps.release.outputs.report-ref != '' }}
53
- uses: actions/upload-artifact@v4
70
+ uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
54
71
  with:
55
72
  name: ts-release-report-${{ github.run_attempt }}
56
73
  path: ${{ steps.release.outputs.report-ref }}