@arc-e-tect/api-only-publisher 0.8.0 → 0.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.adoc +130 -7
- package/package.json +1 -1
- package/src/channel-error.js +7 -0
- package/src/channels.js +90 -9
- package/src/config.js +17 -1
- package/src/examples.js +172 -0
- package/src/nuget.js +275 -0
- package/src/pack.js +30 -8
- package/src/pipeline.js +34 -1
- package/src/tar.js +66 -0
- package/src/zip.js +103 -0
package/README.adoc
CHANGED
|
@@ -6,12 +6,12 @@
|
|
|
6
6
|
:source-highlighter: rouge
|
|
7
7
|
// The released version of this component: its snippets use it. The release
|
|
8
8
|
// workflow updates it; do not change it by hand.
|
|
9
|
-
:api-only-publisher-version: 0.
|
|
9
|
+
:api-only-publisher-version: 0.10.0
|
|
10
10
|
|
|
11
11
|
image:https://github.com/Arc-E-Tect/SoftwareEngineeringDoneRight-API/actions/workflows/nvd-cache-refresh.yml/badge.svg[Vulnerability Scan,link=https://github.com/Arc-E-Tect/SoftwareEngineeringDoneRight-API/actions/workflows/nvd-cache-refresh.yml]
|
|
12
12
|
image:https://img.shields.io/npm/v/@arc-e-tect/api-only-publisher[npm,link=https://www.npmjs.com/package/@arc-e-tect/api-only-publisher]
|
|
13
13
|
image:https://img.shields.io/github/v/release/Arc-E-Tect/SoftwareEngineeringDoneRight-API?filter=api-only-publisher-v*[GitHub release,link=https://github.com/Arc-E-Tect/SoftwareEngineeringDoneRight-API/releases?q=api-only-publisher]
|
|
14
|
-
image:https://img.shields.io/badge/Node-22%2B-339933?logo=node.js[Node 22+,link=https://nodejs.org]
|
|
14
|
+
image:https://img.shields.io/badge/Node-22.14%2B-339933?logo=node.js[Node 22.14+,link=https://nodejs.org]
|
|
15
15
|
image:https://img.shields.io/badge/License-MIT-blue[MIT,link=LICENSE]
|
|
16
16
|
|
|
17
17
|
[NOTE]
|
|
@@ -344,6 +344,9 @@ A `publish: false` target keeps the version its source declares.
|
|
|
344
344
|
. **Lint** the finished document, so a broken fragment surfaces against the target it actually affects.
|
|
345
345
|
`build` prints the linter's output only when the lint fails, and writes no report; to see warnings, and to write each document's report under `reports.lint`, run `lint`.
|
|
346
346
|
|
|
347
|
+
. **Report operations with no example**, after the lint and apart from it: an AsyncAPI message without `examples`, or an OpenAPI request body or response without one, is logged as a finding, and `lint.examples.<kind>` decides whether that fails the build (<<examples-check,An operation with no example is reported apart from lint §>>).
|
|
348
|
+
Only `build` does this; `lint` does not, and no report file is written.
|
|
349
|
+
|
|
347
350
|
[#versions]
|
|
348
351
|
== Versions
|
|
349
352
|
|
|
@@ -606,7 +609,7 @@ Configured portfolio: paths=none, location=portfolios
|
|
|
606
609
|
|`file`
|
|
607
610
|
|A local directory, laid out as `<directory>/<target>/<version>/`.
|
|
608
611
|
Not a distribution mechanism between repositories: it makes the pipeline testable end to end with no infrastructure, lets a library share one build with the projects that implement it, and is a local-iteration escape hatch.
|
|
609
|
-
Publishing a version again
|
|
612
|
+
Publishing a version again writes it again and keeps the others (<<idempotent,Publishing the same version again §>>); with `clean: true` only the version being published is kept, for a directory that is a build output rather than a record of releases.
|
|
610
613
|
|
|
611
614
|
|`maven`
|
|
612
615
|
|A path publishes into a repository layout on disk; an `http(s)` URL deploys to a remote repository with an HTTP `PUT` per file and a bearer token.
|
|
@@ -618,20 +621,109 @@ Native for Gradle consumers: version resolution, caching and conflict handling c
|
|
|
618
621
|
|`github-release`
|
|
619
622
|
|The language-neutral floor: immutable per tag, works for private repositories with a token, readable by a consumer with no JVM and no Node.js.
|
|
620
623
|
No dependency-resolution semantics and no update notification, which is why it is the fallback rather than the default.
|
|
624
|
+
|
|
625
|
+
|`nuget`
|
|
626
|
+
|A path writes a `.nupkg` into a local feed; an `http(s)` URL -- the feed's service index -- pushes to a remote one.
|
|
627
|
+
Native for .NET consumers: a `PackageReference` at a version, restored from a feed. See <<nuget,The `nuget` channel §>>.
|
|
628
|
+
|===
|
|
629
|
+
|
|
630
|
+
[#idempotent]
|
|
631
|
+
=== Publishing the same version again
|
|
632
|
+
|
|
633
|
+
`publish` is idempotent: publishing a version again with the same content changes nothing, on every channel.
|
|
634
|
+
|
|
635
|
+
That rests on `pack` being reproducible.
|
|
636
|
+
The same documents always pack to the same archive, byte for byte: the archive is written with a fixed mode, no owner and a fixed timestamp, and `manifest.json`'s `producedAt` is not the time `pack` ran but the time of the commit being packed.
|
|
637
|
+
Set `SOURCE_DATE_EPOCH` (seconds since 1970, the https://reproducible-builds.org/specs/source-date-epoch/[reproducible-builds convention ↗]) to choose another; outside a git repository, where there is no commit, it is the only way to make two packs identical, and without it `producedAt` is the current time.
|
|
638
|
+
|
|
639
|
+
What happens when the destination already has the version:
|
|
640
|
+
|
|
641
|
+
[cols="1,3", options="header"]
|
|
642
|
+
|===
|
|
643
|
+
|Destination |Already has the version
|
|
644
|
+
|
|
645
|
+
|`file`; `maven` and `nuget` to a directory; `npm` with `publish: false`
|
|
646
|
+
|Written again. With the same content the bytes are the same; with different content the version is replaced, and every subscriber whose lockfile recorded the old bytes fails its build until the change is published as a new version.
|
|
647
|
+
|
|
648
|
+
|`maven` and `nuget` to a URL, `npm`, `github-release`
|
|
649
|
+
|The same bytes: nothing is sent, and the log says `already published, identical`. Different bytes: `publish` fails, naming the version -- a published version is never replaced, so publish the change as a new version.
|
|
650
|
+
`github-release` compares the asset it would upload with the one the release has, and never overwrites one.
|
|
651
|
+
A NuGet feed that cannot return the package it has, so there is nothing to compare, fails as a different one would.
|
|
621
652
|
|===
|
|
622
653
|
|
|
623
654
|
Credentials are read from the environment, never from configuration.
|
|
624
|
-
`channels.maven.tokenEnv` names the variable, `MAVEN_TOKEN` by default; a configuration file gets committed, and a credential in a committed file is a credential that has leaked.
|
|
655
|
+
`channels.maven.tokenEnv` names the variable, `MAVEN_TOKEN` by default, and `channels.nuget.tokenEnv` the NuGet API key's, `NUGET_API_KEY` by default; a configuration file gets committed, and a credential in a committed file is a credential that has leaked.
|
|
625
656
|
|
|
626
657
|
link:../docs/reference/how-publisher-and-subscriber-work-together.adoc#channels[How the Publisher and the Subscriber work together →] lists each channel's layout and options.
|
|
627
658
|
|
|
659
|
+
[#nuget]
|
|
660
|
+
=== The `nuget` channel
|
|
661
|
+
|
|
662
|
+
It is published without the .NET SDK, as the Maven channel is published without Maven: a `.nupkg` is a ZIP archive the Publisher writes itself, and a push is one HTTP request.
|
|
663
|
+
|
|
664
|
+
[source,yaml]
|
|
665
|
+
----
|
|
666
|
+
channels:
|
|
667
|
+
nuget:
|
|
668
|
+
idPrefix: Contoso.Contracts # required
|
|
669
|
+
# packageId: Contoso.Users # replaces the whole id, for every target
|
|
670
|
+
repository: build/nuget-feed # a directory, or a feed's service index URL
|
|
671
|
+
# tokenEnv: NUGET_API_KEY # the variable holding the API key, for a URL
|
|
672
|
+
# authors: Contoso API team # default: idPrefix
|
|
673
|
+
# license: MIT # an SPDX expression; default: UNLICENSED
|
|
674
|
+
----
|
|
675
|
+
|
|
676
|
+
*The package id* is `<idPrefix>.<target in PascalCase>`: target `user-account` with `idPrefix: Contoso.Contracts` is `Contoso.Contracts.UserAccount`, the dotted PascalCase .NET uses for namespaces, from a kebab-case target.
|
|
677
|
+
`packageId` replaces it.
|
|
678
|
+
An id NuGet would refuse -- anything but letters, digits and underscores in parts separated by single dots or dashes, or longer than 100 characters -- is refused before anything is written.
|
|
679
|
+
|
|
680
|
+
*The version* is the contract's, and a pre-release is a NuGet pre-release: `2.1.0-rc.1` is not picked by a floating range such as `2.1.*`, only when asked for.
|
|
681
|
+
A version with build metadata, such as `2.1.0+build.7`, is refused before anything is written: NuGet ignores `+metadata` when it identifies a package, so it would be the same package as every other build of `2.1.0`, and a feed refuses the second.
|
|
682
|
+
|
|
683
|
+
*A local feed* is a directory in the layout `nuget add` writes, which the NuGet documentation recommends over a flat folder:
|
|
684
|
+
|
|
685
|
+
----
|
|
686
|
+
<repository>/<id, lower case>/<version>/
|
|
687
|
+
<id>.<version>.nupkg
|
|
688
|
+
<id>.<version>.nupkg.sha512
|
|
689
|
+
<id>.nuspec
|
|
690
|
+
manifest.json -- beside the package, so provenance is readable without unpacking it
|
|
691
|
+
----
|
|
692
|
+
|
|
693
|
+
A .NET build uses it with `dotnet nuget add source <repository>`, or a `nuget.config` entry.
|
|
694
|
+
|
|
695
|
+
*A remote feed* is configured by its service index, the URL a feed's settings show and that ends in `index.json`, such as `https://api.nuget.org/v3/index.json`.
|
|
696
|
+
The Publisher reads the service index for its `PackagePublish/2.0.0` endpoint, then pushes the package there with one `PUT`, as `multipart/form-data`, with the key from the environment variable `tokenEnv` names in the `X-NuGet-ApiKey` header.
|
|
697
|
+
A feed that already has the version answers 409; the Publisher then reads the feed's copy back (its `PackageBaseAddress/3.0.0` resource) and succeeds when it is the same package, and fails when it is not, or cannot be read (<<idempotent,Publishing the same version again §>>).
|
|
698
|
+
Without the variable set, the push fails before anything is sent, naming it.
|
|
699
|
+
That is how nuget.org, GitHub Packages and self-hosted feeds such as BaGet take a push.
|
|
700
|
+
A feed that authenticates some other way -- Azure Artifacts, which expects a credential provider or a token in an `Authorization` header -- is not supported yet; only the local-HTTP-stub tests exercise the push, not a real feed.
|
|
701
|
+
|
|
702
|
+
*The package holds* the `.nuspec`, the two Open Packaging Conventions parts (`[Content_Types].xml` and `_rels/.rels`), and exactly what `pack` produced -- the documents and `manifest.json` -- extracted, never rebuilt, under `contracts/`:
|
|
703
|
+
|
|
704
|
+
----
|
|
705
|
+
Contoso.Contracts.UserAccount.nuspec
|
|
706
|
+
[Content_Types].xml
|
|
707
|
+
_rels/.rels
|
|
708
|
+
contracts/asyncapi.yaml
|
|
709
|
+
contracts/manifest.json
|
|
710
|
+
contracts/openapi.yaml
|
|
711
|
+
----
|
|
712
|
+
|
|
713
|
+
`contracts/` is the contract with the consumer, and changes only with a major version of the Publisher.
|
|
714
|
+
NuGet gives the folder no behaviour: nothing is copied into a project, nothing is compiled, and a restore reports no warning for a package with no assemblies.
|
|
715
|
+
Until a `.targets` file places and verifies the documents for a .NET build, a consumer reads them where restore put them, `~/.nuget/packages/<id, lower case>/<version>/contracts/` (or `$(NuGetPackageRoot)` in MSBuild), and verifies them against `contracts/manifest.json`.
|
|
716
|
+
|
|
717
|
+
The same packed archive always produces a byte-identical package: entries in a fixed order, a fixed timestamp.
|
|
718
|
+
Since `pack` is reproducible, publishing the same commit again produces the same package too, so a republish to a local feed can be checked as unchanged.
|
|
719
|
+
|
|
628
720
|
[#why-registries]
|
|
629
|
-
=== Why npm and
|
|
721
|
+
=== Why npm, Maven and NuGet rather than release assets alone
|
|
630
722
|
|
|
631
723
|
Publishing is passive.
|
|
632
724
|
The producer releases `2.1.0` and nothing happens in an implementation repository until somebody looks.
|
|
633
725
|
|
|
634
|
-
Renovate and Dependabot understand npm and
|
|
726
|
+
Renovate and Dependabot understand npm, Maven and NuGet natively, so a contract bump arrives downstream as a pull request without any bespoke machinery.
|
|
635
727
|
That is the practical argument for preferring them, and the reason to design the coordinates so that standard update bots can read them.
|
|
636
728
|
|
|
637
729
|
[#pre-releases]
|
|
@@ -687,6 +779,9 @@ reports:
|
|
|
687
779
|
lint: build/reports/lint
|
|
688
780
|
lint:
|
|
689
781
|
unreferenced: error # a fragment no target reaches: error, warn or off
|
|
782
|
+
examples:
|
|
783
|
+
asyncapi: warn # a message with no example: error, warn (the default) or off
|
|
784
|
+
openapi: warn # a request body or response with no example: error, warn (the default) or off
|
|
690
785
|
|
|
691
786
|
toolchain:
|
|
692
787
|
redocly: "@redocly/cli@2.52.0"
|
|
@@ -781,6 +876,34 @@ Any other YAML file under the source root counts.
|
|
|
781
876
|
|
|
782
877
|
`lint` lints every document even when one of them fails, and names every failure at the end, so a single run reports all of them.
|
|
783
878
|
|
|
879
|
+
[#examples-check]
|
|
880
|
+
=== An operation with no example is reported apart from lint
|
|
881
|
+
|
|
882
|
+
`build` also checks, after linting, whether each operation's example is one the rest of the API-Only toolchain could use -- and reports that separately from lint's own findings, since "your contract is wrong" and "your contract limits what you can do with it downstream" are different claims.
|
|
883
|
+
Neither specification requires an example, and this is not a specification-validity rule: it is never run as a Redocly plugin or a Spectral ruleset.
|
|
884
|
+
|
|
885
|
+
For AsyncAPI, it is whether a message's own `examples` array is non-empty -- what Microcks reads to build its async mocks and its conformance tests:
|
|
886
|
+
|
|
887
|
+
[source,text]
|
|
888
|
+
----
|
|
889
|
+
AsyncAPI operation '<operationId>': message '<messageKey>' (<fragment, or where in the bundle>) carries no example, so this bundle cannot be used for Microcks-based conformance testing.
|
|
890
|
+
----
|
|
891
|
+
|
|
892
|
+
For OpenAPI, it is whether a request body's or response's media type declares `example`/`examples`, or the schema it references carries its own top-level `examples`.
|
|
893
|
+
Nothing downstream currently reads an OpenAPI example, so the message never claims the bundle cannot be used -- only that the generated documentation is weaker without one:
|
|
894
|
+
|
|
895
|
+
[source,text]
|
|
896
|
+
----
|
|
897
|
+
OpenAPI operation '<operationId>': <request body, or response <status>> (<fragment, or where in the bundle>) carries no example, which makes the generated documentation harder to read.
|
|
898
|
+
----
|
|
899
|
+
|
|
900
|
+
A body or response with no `content` at all, such as a `204`, has nothing to exemplify and is never reported.
|
|
901
|
+
|
|
902
|
+
`lint.examples.asyncapi` and `lint.examples.openapi` each default to `warn`; `error` fails the build, `off` does not look.
|
|
903
|
+
The findings appear in the build log only: `lint` does not run this check, and there is no report file, so a job that only runs `lint` never sees them -- run `build`, and read its log.
|
|
904
|
+
With `error`, the build fails with `<n> operation(s) in <file> have no example (lint.examples.<kind> is error):`, followed by each finding.
|
|
905
|
+
A library that has adopted the Microcks-based conformance emitter will usually want `lint.examples.asyncapi: error`: publishing a bundle its own toolchain cannot use is a real defect there, not merely a gap (link:../docs/reference/authoring-a-specification-library.adoc#give-every-operation-an-example[Give every operation an example →]).
|
|
906
|
+
|
|
784
907
|
[#version-stamping]
|
|
785
908
|
=== Version stamping edits one scalar
|
|
786
909
|
|
|
@@ -1005,7 +1128,7 @@ Anything else is a bug; please report it.
|
|
|
1005
1128
|
npm test
|
|
1006
1129
|
----
|
|
1007
1130
|
|
|
1008
|
-
Covers every module and every command: placeholder substitution, version stamping, version files, configuration parsing, closures, packing, channels, splitting, and the scaffold.
|
|
1131
|
+
Covers every module and every command: placeholder substitution, version stamping, version files, configuration parsing, closures, packing, channels, splitting, aggregates, the example check, and the scaffold.
|
|
1009
1132
|
`build.e2e.test.js` runs the real bundlers.
|
|
1010
1133
|
The run fails below 90% of lines and functions, or 80% of branches.
|
|
1011
1134
|
|
package/package.json
CHANGED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
// The error every distribution channel throws, in a module of its own so that a channel
|
|
4
|
+
// implemented outside channels.js can throw it without requiring channels.js back.
|
|
5
|
+
class ChannelError extends Error {}
|
|
6
|
+
|
|
7
|
+
module.exports = { ChannelError };
|
package/src/channels.js
CHANGED
|
@@ -13,8 +13,9 @@ const path = require("path");
|
|
|
13
13
|
const { execFileSync } = require("child_process");
|
|
14
14
|
|
|
15
15
|
const { isPrerelease, npmDistTag } = require("./version-policy");
|
|
16
|
+
const { ChannelError } = require("./channel-error");
|
|
17
|
+
const { publishNuget } = require("./nuget");
|
|
16
18
|
|
|
17
|
-
class ChannelError extends Error {}
|
|
18
19
|
|
|
19
20
|
/**
|
|
20
21
|
* `file` -- publish to a local directory.
|
|
@@ -150,8 +151,23 @@ function publishNpm(archive, manifest, options, log) {
|
|
|
150
151
|
return { location, distTag, name };
|
|
151
152
|
}
|
|
152
153
|
|
|
153
|
-
|
|
154
|
-
|
|
154
|
+
// Idempotent: a version the registry already has is left alone when it is the
|
|
155
|
+
// same package, and refused when it is not -- npm never replaces a version.
|
|
156
|
+
const registry = options.registry ? ["--registry", options.registry] : [];
|
|
157
|
+
const local = JSON.parse(execFileSync("npm", ["pack", "--dry-run", "--json"],
|
|
158
|
+
{ cwd: workDir, encoding: "utf8", stdio: "pipe" }))[0].integrity;
|
|
159
|
+
const published = npmPublishedIntegrity(name, version, registry);
|
|
160
|
+
if (published === local) {
|
|
161
|
+
log(`-- npm ${name}@${version} is already published, identical; nothing to do`);
|
|
162
|
+
return { location: name + "@" + version, distTag, name, alreadyPublished: true };
|
|
163
|
+
}
|
|
164
|
+
if (published) {
|
|
165
|
+
throw new ChannelError(
|
|
166
|
+
`npm already has ${name}@${version}, and it is not this package (${published}, not ${local}). ` +
|
|
167
|
+
"A published version is never replaced; publish the change as a new version.");
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
const args = ["publish", "--tag", distTag, ...registry];
|
|
155
171
|
// A signed attestation tying this tarball to the commit and workflow run
|
|
156
172
|
// that produced it. A tool whose whole purpose is making provenance
|
|
157
173
|
// auditable should not ask to be taken on trust itself. Requires a public
|
|
@@ -166,6 +182,19 @@ function publishNpm(archive, manifest, options, log) {
|
|
|
166
182
|
}
|
|
167
183
|
}
|
|
168
184
|
|
|
185
|
+
/** The integrity npm records for `name@version`, or null when it has no such version. */
|
|
186
|
+
function npmPublishedIntegrity(name, version, registry) {
|
|
187
|
+
try {
|
|
188
|
+
const out = execFileSync("npm", ["view", `${name}@${version}`, "dist.integrity", ...registry],
|
|
189
|
+
{ encoding: "utf8", stdio: "pipe" }).trim();
|
|
190
|
+
return out || null;
|
|
191
|
+
} catch (error) {
|
|
192
|
+
const output = `${error.stdout || ""}${error.stderr || ""}`;
|
|
193
|
+
if (/E404/.test(output)) return null;
|
|
194
|
+
throw new ChannelError(`could not ask npm whether ${name}@${version} is published: ${output.trim() || error.message}`);
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
169
198
|
/**
|
|
170
199
|
* `github-release` -- attach the archive to a GitHub release.
|
|
171
200
|
*
|
|
@@ -204,7 +233,29 @@ function publishGithubRelease(archive, manifest, options, log) {
|
|
|
204
233
|
gh(args);
|
|
205
234
|
}
|
|
206
235
|
|
|
207
|
-
|
|
236
|
+
// Idempotent, as the registries are: an asset the release already has is left alone
|
|
237
|
+
// when it is the same bytes, and refused when it is not. Never --clobber: that would
|
|
238
|
+
// replace a published contract under the same version.
|
|
239
|
+
const asset = path.basename(archive);
|
|
240
|
+
const assets = exists
|
|
241
|
+
? gh(["release", "view", tag, "--repo", repository, "--json", "assets", "--jq", ".assets[].name"]).split("\n").map((a) => a.trim())
|
|
242
|
+
: [];
|
|
243
|
+
if (assets.includes(asset)) {
|
|
244
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "api-only-gh-"));
|
|
245
|
+
try {
|
|
246
|
+
gh(["release", "download", tag, "--repo", repository, "--pattern", asset, "--dir", dir]);
|
|
247
|
+
if (fs.readFileSync(path.join(dir, asset)).equals(fs.readFileSync(archive))) {
|
|
248
|
+
log(`-- ${repository} release ${tag} already has ${asset}, identical; nothing to do`);
|
|
249
|
+
return { location: `${repository}@${tag}`, tag, alreadyPublished: true };
|
|
250
|
+
}
|
|
251
|
+
} finally {
|
|
252
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
253
|
+
}
|
|
254
|
+
throw new ChannelError(
|
|
255
|
+
`${repository} release ${tag} already has ${asset}, and it is not this archive. ` +
|
|
256
|
+
"A published version is never replaced; publish the change as a new version.");
|
|
257
|
+
}
|
|
258
|
+
gh(["release", "upload", tag, archive, "--repo", repository]);
|
|
208
259
|
log(`-- Published ${manifest.target} ${manifest.version} to ${repository} release ${tag}`);
|
|
209
260
|
return { location: `${repository}@${tag}`, tag };
|
|
210
261
|
}
|
|
@@ -241,6 +292,18 @@ async function putFile(url, body, token, contentType) {
|
|
|
241
292
|
}
|
|
242
293
|
}
|
|
243
294
|
|
|
295
|
+
/** What a Maven repository holds at `url`, or null when it holds nothing there. */
|
|
296
|
+
async function getFile(url, token) {
|
|
297
|
+
const response = await fetch(url, { headers: { "Authorization": `Bearer ${token}` } });
|
|
298
|
+
if (response.status === 404) return null;
|
|
299
|
+
if (!response.ok) {
|
|
300
|
+
throw new ChannelError(`GET ${url} failed: ${response.status} ${response.statusText}. ` +
|
|
301
|
+
(response.status === 401 || response.status === 403
|
|
302
|
+
? "Check the token in the environment variable named by channels.maven.tokenEnv." : ""));
|
|
303
|
+
}
|
|
304
|
+
return Buffer.from(await response.arrayBuffer());
|
|
305
|
+
}
|
|
306
|
+
|
|
244
307
|
function publishMavenRemote(archive, manifest, options, log) {
|
|
245
308
|
const tokenEnv = options.tokenEnv || "MAVEN_TOKEN";
|
|
246
309
|
const token = process.env[tokenEnv];
|
|
@@ -258,24 +321,42 @@ function publishMavenRemote(archive, manifest, options, log) {
|
|
|
258
321
|
`${options.groupId.split(".").join("/")}/${artifactId}/${version}/${artifactId}-${version}`;
|
|
259
322
|
|
|
260
323
|
const work = (async () => {
|
|
261
|
-
|
|
324
|
+
// Idempotent: an artifact the repository already has is left alone when it is
|
|
325
|
+
// the same bytes, and refused when it is not.
|
|
326
|
+
const bytes = fs.readFileSync(archive);
|
|
327
|
+
const existing = await getFile(`${base}.${extension}`, token);
|
|
328
|
+
if (existing) {
|
|
329
|
+
if (existing.equals(bytes)) return true;
|
|
330
|
+
throw new ChannelError(
|
|
331
|
+
`${options.repository} already has ${options.groupId}:${artifactId}:${version}, and it is not this archive. ` +
|
|
332
|
+
"A published version is never replaced; publish the change as a new version.");
|
|
333
|
+
}
|
|
334
|
+
await putFile(`${base}.${extension}`, bytes, token, "application/octet-stream");
|
|
262
335
|
await putFile(`${base}.pom`,
|
|
263
336
|
Buffer.from(pom(options.groupId, artifactId, version, extension), "utf8"),
|
|
264
337
|
token, "application/xml");
|
|
265
338
|
await putFile(`${base}-manifest.json`,
|
|
266
339
|
Buffer.from(JSON.stringify(manifest, null, 2) + "\n", "utf8"),
|
|
267
340
|
token, "application/json");
|
|
341
|
+
return false;
|
|
268
342
|
})();
|
|
269
343
|
|
|
270
344
|
// The CLI is synchronous throughout; surfacing the promise here would make
|
|
271
345
|
// every caller async for one channel's benefit.
|
|
272
|
-
return work.then(() => {
|
|
273
|
-
log(
|
|
274
|
-
|
|
346
|
+
return work.then((alreadyPublished) => {
|
|
347
|
+
log(alreadyPublished
|
|
348
|
+
? `-- ${options.groupId}:${artifactId}:${version} is already in ${options.repository}, identical; nothing to do`
|
|
349
|
+
: `-- Deployed ${options.groupId}:${artifactId}:${version} to ${options.repository}`);
|
|
350
|
+
return {
|
|
351
|
+
location: `${base}.${extension}`, coordinates: `${options.groupId}:${artifactId}:${version}@${extension}`,
|
|
352
|
+
...(alreadyPublished ? { alreadyPublished } : {}),
|
|
353
|
+
};
|
|
275
354
|
});
|
|
276
355
|
}
|
|
277
356
|
|
|
278
|
-
const CHANNELS = {
|
|
357
|
+
const CHANNELS = {
|
|
358
|
+
file: publishFile, maven: publishMaven, npm: publishNpm, "github-release": publishGithubRelease, nuget: publishNuget,
|
|
359
|
+
};
|
|
279
360
|
|
|
280
361
|
function publish(archive, manifest, channel, options, log = () => {}) {
|
|
281
362
|
const handler = CHANNELS[channel];
|
package/src/config.js
CHANGED
|
@@ -27,7 +27,8 @@ const DEFAULTS_KIND_KEYS = { openapi: ["lint", "outputName", "fragmentPaths"], a
|
|
|
27
27
|
const PLACEHOLDERS_KEYS = ["strict"];
|
|
28
28
|
const BUILD_KEYS = ["staging", "dist"];
|
|
29
29
|
const REPORTS_KEYS = ["lint"];
|
|
30
|
-
const LINT_KEYS = ["unreferenced"];
|
|
30
|
+
const LINT_KEYS = ["unreferenced", "examples"];
|
|
31
|
+
const LINT_EXAMPLES_KEYS = ["openapi", "asyncapi"];
|
|
31
32
|
const DISTRIBUTION_KEYS = ["root", "layout"];
|
|
32
33
|
const TARGET_KEYS = ["openapi", "asyncapi", "publish", "versionFile"];
|
|
33
34
|
const TARGET_KIND_KEYS = ["bundle", "aggregate", "info"];
|
|
@@ -121,6 +122,7 @@ function load(configPath) {
|
|
|
121
122
|
checkKeys(reports, REPORTS_KEYS, "reports");
|
|
122
123
|
const lint = parsed.lint || {};
|
|
123
124
|
checkKeys(lint, LINT_KEYS, "lint");
|
|
125
|
+
if (lint.examples) checkKeys(lint.examples, LINT_EXAMPLES_KEYS, "lint.examples");
|
|
124
126
|
if (parsed.distribution) checkKeys(parsed.distribution, DISTRIBUTION_KEYS, "distribution");
|
|
125
127
|
|
|
126
128
|
const portfolio = parsed.portfolio || {};
|
|
@@ -284,6 +286,20 @@ function load(configPath) {
|
|
|
284
286
|
}
|
|
285
287
|
return mode;
|
|
286
288
|
},
|
|
289
|
+
// What the build does about an operation whose example this kind's
|
|
290
|
+
// toolchain could use, and does not have: `warn`, the default, reports it;
|
|
291
|
+
// `error` fails the build; `off` does not look. Examples are never
|
|
292
|
+
// mandatory to the specification, so the default suits a library that has
|
|
293
|
+
// not adopted Microcks; a library that has wants `error` for asyncapi.
|
|
294
|
+
lintExamples(kind) {
|
|
295
|
+
const configured = (this.lint.examples || {})[kind];
|
|
296
|
+
const mode = configured === undefined ? "warn" : configured;
|
|
297
|
+
if (!["error", "warn", "off"].includes(mode)) {
|
|
298
|
+
throw new ConfigError(
|
|
299
|
+
`lint.examples.${kind} must be error, warn or off, not ${JSON.stringify(configured)}`);
|
|
300
|
+
}
|
|
301
|
+
return mode;
|
|
302
|
+
},
|
|
287
303
|
tool(name) {
|
|
288
304
|
return requireString(this.toolchain[name], `toolchain.${name}`);
|
|
289
305
|
},
|
package/src/examples.js
ADDED
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
// Whether a bundled document's operations carry examples the rest of the API-Only
|
|
4
|
+
// toolchain can do something with.
|
|
5
|
+
//
|
|
6
|
+
// Both OpenAPI and AsyncAPI make examples optional, and the specification linters
|
|
7
|
+
// are right to accept their absence: a contract without one is a valid contract.
|
|
8
|
+
// This is a toolchain-compatibility check instead -- can Microcks, or the
|
|
9
|
+
// TranscriberJ, do anything useful with what is about to be published -- and the
|
|
10
|
+
// answer, and so the message, differs per kind. Neither runs from a Redocly plugin
|
|
11
|
+
// or a Spectral ruleset: one implementation, one message shape, aware of why it
|
|
12
|
+
// cares, reported apart from either linter's own findings.
|
|
13
|
+
//
|
|
14
|
+
// AsyncAPI: Microcks builds its async mocks and its conformance tests from a
|
|
15
|
+
// message's own `examples` -- an array of {name, summary, payload} on the Message
|
|
16
|
+
// Object itself. A message with none cannot be used for Microcks-based conformance
|
|
17
|
+
// testing at all, so the message says exactly that.
|
|
18
|
+
//
|
|
19
|
+
// OpenAPI: nothing downstream currently reads an example. The TranscriberJ
|
|
20
|
+
// processes a bundle without one perfectly well -- bodies take arguments,
|
|
21
|
+
// constraints become constants. So the OpenAPI message claims only what is true:
|
|
22
|
+
// weaker documentation, never that the bundle cannot be used.
|
|
23
|
+
|
|
24
|
+
const KEY = "x-fragment-path";
|
|
25
|
+
|
|
26
|
+
/** The value at a local JSON pointer (`#/a/b/0`) within `document`, or undefined. */
|
|
27
|
+
function pointer(document, ref) {
|
|
28
|
+
if (typeof ref !== "string" || !ref.startsWith("#/")) return undefined;
|
|
29
|
+
let node = document;
|
|
30
|
+
for (const raw of ref.slice(2).split("/")) {
|
|
31
|
+
if (node === null || typeof node !== "object") return undefined;
|
|
32
|
+
const segment = raw.replace(/~1/g, "/").replace(/~0/g, "~");
|
|
33
|
+
node = node[segment];
|
|
34
|
+
}
|
|
35
|
+
return node;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** `node`, or what it points to when it is a `{ $ref }`. */
|
|
39
|
+
function resolve(document, node) {
|
|
40
|
+
if (node && typeof node === "object" && typeof node.$ref === "string") {
|
|
41
|
+
return pointer(document, node.$ref);
|
|
42
|
+
}
|
|
43
|
+
return node;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Every AsyncAPI operation whose message carries no example, in operation order.
|
|
48
|
+
*
|
|
49
|
+
* An operation naming no message explicitly acts on every message of its channel,
|
|
50
|
+
* so each of those is checked in its place. A message this document cannot resolve
|
|
51
|
+
* -- a dangling `$ref`, which lint would already have refused -- is skipped rather
|
|
52
|
+
* than reported here.
|
|
53
|
+
*
|
|
54
|
+
* @returns {Array<{operationId: string, messageKey: string, fragmentPath: string|null, at: string}>}
|
|
55
|
+
*/
|
|
56
|
+
function asyncapiOperationsWithoutExamples(document) {
|
|
57
|
+
const findings = [];
|
|
58
|
+
const operations = document.operations || {};
|
|
59
|
+
for (const [operationId, operation] of Object.entries(operations)) {
|
|
60
|
+
if (!operation || typeof operation !== "object") continue;
|
|
61
|
+
const channel = resolve(document, operation.channel);
|
|
62
|
+
const named = Array.isArray(operation.messages) ? operation.messages : [];
|
|
63
|
+
const entries = named.length > 0
|
|
64
|
+
? named.map((ref) => [
|
|
65
|
+
typeof ref.$ref === "string" ? ref.$ref.split("/").pop() : null,
|
|
66
|
+
resolve(document, ref),
|
|
67
|
+
])
|
|
68
|
+
: Object.entries((channel && channel.messages) || {});
|
|
69
|
+
|
|
70
|
+
for (const [messageKey, message] of entries) {
|
|
71
|
+
if (!message || typeof message !== "object" || messageKey === null) continue;
|
|
72
|
+
const examples = message.examples;
|
|
73
|
+
if (Array.isArray(examples) && examples.length > 0) continue;
|
|
74
|
+
findings.push({
|
|
75
|
+
operationId,
|
|
76
|
+
messageKey,
|
|
77
|
+
fragmentPath: message[KEY] || null,
|
|
78
|
+
at: `/operations/${operationId}`,
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
return findings;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Whether any media type of a request body or response object has an example. */
|
|
86
|
+
function hasExample(document, bodyOrResponse) {
|
|
87
|
+
const content = bodyOrResponse.content;
|
|
88
|
+
if (!content || typeof content !== "object") return true; // nothing to exemplify
|
|
89
|
+
return Object.values(content).some((mediaType) => {
|
|
90
|
+
if (!mediaType || typeof mediaType !== "object") return false;
|
|
91
|
+
if (mediaType.example !== undefined) return true;
|
|
92
|
+
if (mediaType.examples && Object.keys(mediaType.examples).length > 0) return true;
|
|
93
|
+
const schema = resolve(document, mediaType.schema);
|
|
94
|
+
return !!(schema && Array.isArray(schema.examples) && schema.examples.length > 0);
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const METHODS = ["get", "put", "post", "delete", "options", "head", "patch", "trace"];
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Every OpenAPI operation whose request body or a response carries no example, in
|
|
102
|
+
* path-and-method order.
|
|
103
|
+
*
|
|
104
|
+
* An entity counts as having one when a media type declares `example` or
|
|
105
|
+
* `examples` directly, or when the schema that media type's `schema` resolves to
|
|
106
|
+
* -- not a property inside it -- carries its own top-level `examples`. A body or
|
|
107
|
+
* response with no `content` at all, such as a 204, has nothing to exemplify and
|
|
108
|
+
* is not reported.
|
|
109
|
+
*
|
|
110
|
+
* @returns {Array<{operationId: string, part: string, fragmentPath: string|null, at: string}>}
|
|
111
|
+
*/
|
|
112
|
+
function openapiOperationsWithoutExamples(document) {
|
|
113
|
+
const findings = [];
|
|
114
|
+
const paths = document.paths || {};
|
|
115
|
+
for (const [route, pathItem] of Object.entries(paths)) {
|
|
116
|
+
if (!pathItem || typeof pathItem !== "object") continue;
|
|
117
|
+
for (const method of METHODS) {
|
|
118
|
+
const operation = pathItem[method];
|
|
119
|
+
if (!operation || typeof operation !== "object") continue;
|
|
120
|
+
const operationId = operation.operationId || `${method.toUpperCase()} ${route}`;
|
|
121
|
+
const at = `/paths/${route.replace(/~/g, "~0").replace(/\//g, "~1")}/${method}`;
|
|
122
|
+
|
|
123
|
+
if (operation.requestBody) {
|
|
124
|
+
const body = resolve(document, operation.requestBody);
|
|
125
|
+
if (body && !hasExample(document, body)) {
|
|
126
|
+
findings.push({ operationId, part: "request body", fragmentPath: body[KEY] || null, at });
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
for (const [status, raw] of Object.entries(operation.responses || {})) {
|
|
131
|
+
const response = resolve(document, raw);
|
|
132
|
+
if (response && !hasExample(document, response)) {
|
|
133
|
+
findings.push({
|
|
134
|
+
operationId, part: `response ${status}`, fragmentPath: response[KEY] || null, at,
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
return findings;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* The warning text for one AsyncAPI finding.
|
|
145
|
+
*
|
|
146
|
+
* @param {{operationId: string, messageKey: string, fragmentPath: string|null}} finding
|
|
147
|
+
* @returns {string}
|
|
148
|
+
*/
|
|
149
|
+
function asyncapiMessage(finding) {
|
|
150
|
+
const where = finding.fragmentPath || `at ${finding.at}`;
|
|
151
|
+
return `AsyncAPI operation '${finding.operationId}': message '${finding.messageKey}' (${where}) ` +
|
|
152
|
+
"carries no example, so this bundle cannot be used for Microcks-based conformance testing.";
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* The warning text for one OpenAPI finding.
|
|
157
|
+
*
|
|
158
|
+
* @param {{operationId: string, part: string, fragmentPath: string|null}} finding
|
|
159
|
+
* @returns {string}
|
|
160
|
+
*/
|
|
161
|
+
function openapiMessage(finding) {
|
|
162
|
+
const where = finding.fragmentPath || `at ${finding.at}`;
|
|
163
|
+
return `OpenAPI operation '${finding.operationId}': ${finding.part} (${where}) ` +
|
|
164
|
+
"carries no example, which makes the generated documentation harder to read.";
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
module.exports = {
|
|
168
|
+
asyncapiOperationsWithoutExamples,
|
|
169
|
+
openapiOperationsWithoutExamples,
|
|
170
|
+
asyncapiMessage,
|
|
171
|
+
openapiMessage,
|
|
172
|
+
};
|
package/src/nuget.js
ADDED
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
// `nuget` -- publish as a NuGet package, for a .NET build.
|
|
4
|
+
//
|
|
5
|
+
// Written directly rather than with nuget.exe or `dotnet pack`, as the Maven channel is
|
|
6
|
+
// written without mvn: a specification repository needs no .NET SDK to reach a .NET
|
|
7
|
+
// audience. A .nupkg is a ZIP archive of a .nuspec, two Open Packaging Conventions parts
|
|
8
|
+
// and the content, and a push is one multipart HTTP PUT.
|
|
9
|
+
//
|
|
10
|
+
// The package carries what `pack` produced, extracted, never rebuilt, under `contracts/`:
|
|
11
|
+
// a folder NuGet gives no behaviour, so the path is the whole contract with a consumer
|
|
12
|
+
// that reads it from its global packages folder today, and the later .targets that
|
|
13
|
+
// places and verifies it is free to decide how it is delivered. That path does not change
|
|
14
|
+
// without a major version of the Publisher.
|
|
15
|
+
//
|
|
16
|
+
// Renovate and Dependabot understand NuGet, so a contract bump reaches a .NET consumer as
|
|
17
|
+
// a pull request, as it does an npm or Maven one.
|
|
18
|
+
|
|
19
|
+
const crypto = require("crypto");
|
|
20
|
+
const fs = require("fs");
|
|
21
|
+
const os = require("os");
|
|
22
|
+
const path = require("path");
|
|
23
|
+
const { execFileSync } = require("child_process");
|
|
24
|
+
|
|
25
|
+
const { parse, isPrerelease } = require("./version-policy");
|
|
26
|
+
const { zip } = require("./zip");
|
|
27
|
+
const { ChannelError } = require("./channel-error");
|
|
28
|
+
|
|
29
|
+
/** Where the documents and manifest.json sit inside the package. */
|
|
30
|
+
const CONTENT_FOLDER = "contracts";
|
|
31
|
+
|
|
32
|
+
// NuGet's own rule (NuGet.Client, PackageIdValidator), and nuget.org's length limit.
|
|
33
|
+
const ID = /^\w+([.-]\w+)*$/;
|
|
34
|
+
const ID_MAX_LENGTH = 100;
|
|
35
|
+
|
|
36
|
+
const PUBLISH_RESOURCE = "PackagePublish/2.0.0";
|
|
37
|
+
const CONTENT_RESOURCE = "PackageBaseAddress/3.0.0";
|
|
38
|
+
|
|
39
|
+
function pascalCase(target) {
|
|
40
|
+
return target.split(/[-_.]/).filter(Boolean)
|
|
41
|
+
.map((part) => part[0].toUpperCase() + part.slice(1)).join("");
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* The package id for a target: `packageId` when configured, otherwise
|
|
46
|
+
* `<idPrefix>.<target in PascalCase>`, as the Maven channel takes `groupId` and an
|
|
47
|
+
* optional `artifactId`. Refused, before anything is written, when NuGet would refuse it.
|
|
48
|
+
*/
|
|
49
|
+
function packageId(target, options) {
|
|
50
|
+
let id = options.packageId;
|
|
51
|
+
if (!id) {
|
|
52
|
+
if (!options.idPrefix) throw new ChannelError("the nuget channel requires an idPrefix, such as Contoso.Contracts");
|
|
53
|
+
id = `${options.idPrefix}.${pascalCase(target)}`;
|
|
54
|
+
}
|
|
55
|
+
if (!ID.test(id) || id.length > ID_MAX_LENGTH) {
|
|
56
|
+
throw new ChannelError(
|
|
57
|
+
`'${id}' is not a valid NuGet package id: letters, digits and underscores, in parts ` +
|
|
58
|
+
`separated by single dots or dashes, at most ${ID_MAX_LENGTH} characters. ` +
|
|
59
|
+
"Set channels.nuget.idPrefix, or channels.nuget.packageId, to one that is.");
|
|
60
|
+
}
|
|
61
|
+
return id;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Refuses a version NuGet would not tell apart from another. */
|
|
65
|
+
function checkVersion(version) {
|
|
66
|
+
if (parse(version).build) {
|
|
67
|
+
throw new ChannelError(
|
|
68
|
+
`version '${version}' carries build metadata, which NuGet ignores when it identifies a ` +
|
|
69
|
+
"package: it would be the same package as every other build of " +
|
|
70
|
+
`${version.split("+")[0]}, and a feed refuses the second. Publish it without the +metadata.`);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function escape(text) {
|
|
75
|
+
return String(text).replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** The .nuspec: what the npm channel writes into package.json, in NuGet's terms. */
|
|
79
|
+
function nuspec(id, manifest, options) {
|
|
80
|
+
const source = manifest.source || {};
|
|
81
|
+
const repository = source.repository
|
|
82
|
+
? ` <repository type="git" url="${escape(source.repository)}"` +
|
|
83
|
+
(source.commit ? ` commit="${escape(source.commit)}"` : "") + " />\n"
|
|
84
|
+
: "";
|
|
85
|
+
return '<?xml version="1.0" encoding="utf-8"?>\n' +
|
|
86
|
+
'<package xmlns="http://schemas.microsoft.com/packaging/2013/05/nuspec.xsd">\n' +
|
|
87
|
+
" <metadata>\n" +
|
|
88
|
+
` <id>${escape(id)}</id>\n` +
|
|
89
|
+
` <version>${escape(manifest.version)}</version>\n` +
|
|
90
|
+
` <authors>${escape(options.authors || options.idPrefix || id)}</authors>\n` +
|
|
91
|
+
` <description>API description documents for ${escape(manifest.target)}.</description>\n` +
|
|
92
|
+
` <license type="expression">${escape(options.license || "UNLICENSED")}</license>\n` +
|
|
93
|
+
repository +
|
|
94
|
+
" </metadata>\n" +
|
|
95
|
+
"</package>\n";
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function contentTypes(names) {
|
|
99
|
+
// Not path.extname: it reads "_rels/.rels" as a dotfile with no extension, and OPC needs "rels".
|
|
100
|
+
const extension = (name) => {
|
|
101
|
+
const base = path.posix.basename(name);
|
|
102
|
+
return base.includes(".") ? base.slice(base.lastIndexOf(".") + 1) : "";
|
|
103
|
+
};
|
|
104
|
+
const extensions = [...new Set(names.map(extension).filter(Boolean))].sort();
|
|
105
|
+
const types = extensions.map((extension) =>
|
|
106
|
+
`<Default Extension="${escape(extension)}" ContentType="${extension === "rels"
|
|
107
|
+
? "application/vnd.openxmlformats-package.relationships+xml" : "application/octet"}" />`);
|
|
108
|
+
return '<?xml version="1.0" encoding="utf-8"?>' +
|
|
109
|
+
`<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">${types.join("")}</Types>`;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function relationships(nuspecName) {
|
|
113
|
+
return '<?xml version="1.0" encoding="utf-8"?>' +
|
|
114
|
+
'<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">' +
|
|
115
|
+
`<Relationship Type="http://schemas.microsoft.com/packaging/2010/07/manifest" Target="/${escape(nuspecName)}" Id="Rnuspec" />` +
|
|
116
|
+
"</Relationships>";
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* The .nupkg bytes: the .nuspec, the packaging parts, then everything the packed archive
|
|
121
|
+
* holds, extracted and sorted. The same archive always yields the same bytes.
|
|
122
|
+
*/
|
|
123
|
+
function nupkg(archive, id, manifest, options) {
|
|
124
|
+
const work = fs.mkdtempSync(path.join(os.tmpdir(), "api-only-nuget-"));
|
|
125
|
+
try {
|
|
126
|
+
execFileSync("tar", ["-xzf", archive, "-C", work], { encoding: "utf8" });
|
|
127
|
+
const content = fs.readdirSync(work).sort().map((name) => ({
|
|
128
|
+
name: `${CONTENT_FOLDER}/${name}`, data: fs.readFileSync(path.join(work, name)),
|
|
129
|
+
}));
|
|
130
|
+
const nuspecName = `${id}.nuspec`;
|
|
131
|
+
const rels = "_rels/.rels";
|
|
132
|
+
const names = [nuspecName, rels, ...content.map((entry) => entry.name)];
|
|
133
|
+
return {
|
|
134
|
+
bytes: zip([
|
|
135
|
+
{ name: nuspecName, data: Buffer.from(nuspec(id, manifest, options), "utf8") },
|
|
136
|
+
{ name: "[Content_Types].xml", data: Buffer.from(contentTypes(names), "utf8") },
|
|
137
|
+
{ name: rels, data: Buffer.from(relationships(nuspecName), "utf8") },
|
|
138
|
+
...content,
|
|
139
|
+
]),
|
|
140
|
+
nuspec: nuspec(id, manifest, options),
|
|
141
|
+
};
|
|
142
|
+
} finally {
|
|
143
|
+
fs.rmSync(work, { recursive: true, force: true });
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Write the package into a local feed, in the hierarchical layout `nuget add` writes --
|
|
149
|
+
* `<id>/<version>/<id>.<version>.nupkg`, lower-cased, with the .sha512 and .nuspec beside
|
|
150
|
+
* it -- plus manifest.json, so provenance is readable without unpacking, as in Maven.
|
|
151
|
+
*/
|
|
152
|
+
function publishLocal(archive, manifest, options, id, log) {
|
|
153
|
+
const { bytes, nuspec: spec } = nupkg(archive, id, manifest, options);
|
|
154
|
+
const lowerId = id.toLowerCase();
|
|
155
|
+
const lowerVersion = String(manifest.version).toLowerCase();
|
|
156
|
+
const feed = path.resolve(options.baseDir || ".", options.repository);
|
|
157
|
+
const dir = path.join(feed, lowerId, lowerVersion);
|
|
158
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
159
|
+
|
|
160
|
+
const file = path.join(dir, `${lowerId}.${lowerVersion}.nupkg`);
|
|
161
|
+
fs.writeFileSync(file, bytes);
|
|
162
|
+
fs.writeFileSync(`${file}.sha512`, crypto.createHash("sha512").update(bytes).digest("base64"));
|
|
163
|
+
fs.writeFileSync(path.join(dir, `${lowerId}.nuspec`), spec);
|
|
164
|
+
fs.writeFileSync(path.join(dir, "manifest.json"), JSON.stringify(manifest, null, 2) + "\n");
|
|
165
|
+
|
|
166
|
+
log(`-- Published NuGet ${id} ${manifest.version} to ${feed}`);
|
|
167
|
+
return { location: file, id, version: manifest.version, prerelease: isPrerelease(manifest.version) };
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/** The resources a feed's service index lists. */
|
|
171
|
+
async function feedResources(index) {
|
|
172
|
+
const response = await fetch(index);
|
|
173
|
+
if (!response.ok) {
|
|
174
|
+
throw new ChannelError(`could not read the NuGet service index at ${index}: ${response.status} ${response.statusText}`);
|
|
175
|
+
}
|
|
176
|
+
const resources = (await response.json()).resources || [];
|
|
177
|
+
const find = (type) => (resources.find((r) => r["@type"] === type) || {})["@id"] || null;
|
|
178
|
+
const publish = find(PUBLISH_RESOURCE);
|
|
179
|
+
if (!publish) {
|
|
180
|
+
throw new ChannelError(`the NuGet feed at ${index} has no ${PUBLISH_RESOURCE} resource, so it cannot be pushed to`);
|
|
181
|
+
}
|
|
182
|
+
return { publish, content: find(CONTENT_RESOURCE) };
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Whether the feed's copy of this version is these bytes: true or false, or null when the
|
|
187
|
+
* feed offers no way to read it back.
|
|
188
|
+
*/
|
|
189
|
+
async function sameAsPublished(content, id, version, bytes, key) {
|
|
190
|
+
if (!content) return null;
|
|
191
|
+
const lowerId = id.toLowerCase();
|
|
192
|
+
const lowerVersion = String(version).toLowerCase();
|
|
193
|
+
const url = `${content.replace(/\/?$/, "/")}${lowerId}/${lowerVersion}/${lowerId}.${lowerVersion}.nupkg`;
|
|
194
|
+
const response = await fetch(url, { headers: { "X-NuGet-ApiKey": key } });
|
|
195
|
+
if (!response.ok) return null;
|
|
196
|
+
return Buffer.from(await response.arrayBuffer()).equals(bytes);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* Push the package to a remote feed: one PUT to the endpoint the service index names,
|
|
201
|
+
* the package as the first part of a multipart body, the key in X-NuGet-ApiKey.
|
|
202
|
+
*
|
|
203
|
+
* The key is read from the environment, never from configuration, as the Maven channel
|
|
204
|
+
* reads its token.
|
|
205
|
+
*/
|
|
206
|
+
function publishRemote(archive, manifest, options, id, log) {
|
|
207
|
+
const tokenEnv = options.tokenEnv || "NUGET_API_KEY";
|
|
208
|
+
const key = process.env[tokenEnv];
|
|
209
|
+
if (!key) {
|
|
210
|
+
throw new ChannelError(
|
|
211
|
+
`no API key in $${tokenEnv}, which channels.nuget.tokenEnv names. ` +
|
|
212
|
+
"Remote publication needs one; set it in the release job's environment.");
|
|
213
|
+
}
|
|
214
|
+
const { bytes } = nupkg(archive, id, manifest, options);
|
|
215
|
+
|
|
216
|
+
return (async () => {
|
|
217
|
+
const { publish: endpoint, content } = await feedResources(options.repository);
|
|
218
|
+
const boundary = `api-only-${crypto.createHash("sha256").update(bytes).digest("hex").slice(0, 32)}`;
|
|
219
|
+
const body = Buffer.concat([
|
|
220
|
+
Buffer.from(`--${boundary}\r\nContent-Disposition: form-data; name="package"; filename="package.nupkg"\r\n` +
|
|
221
|
+
"Content-Type: application/octet-stream\r\n\r\n", "utf8"),
|
|
222
|
+
bytes,
|
|
223
|
+
Buffer.from(`\r\n--${boundary}--\r\n`, "utf8"),
|
|
224
|
+
]);
|
|
225
|
+
const response = await fetch(endpoint, {
|
|
226
|
+
method: "PUT",
|
|
227
|
+
headers: {
|
|
228
|
+
"X-NuGet-ApiKey": key,
|
|
229
|
+
"Content-Type": `multipart/form-data; boundary=${boundary}`,
|
|
230
|
+
"Content-Length": String(body.length),
|
|
231
|
+
},
|
|
232
|
+
body,
|
|
233
|
+
});
|
|
234
|
+
if (response.status === 409) {
|
|
235
|
+
// Idempotent: the same package again is success, a different one is not.
|
|
236
|
+
const same = await sameAsPublished(content, id, manifest.version, bytes, key);
|
|
237
|
+
if (same) {
|
|
238
|
+
log(`-- NuGet ${id} ${manifest.version} is already published, identical; nothing to do`);
|
|
239
|
+
return {
|
|
240
|
+
location: endpoint, id, version: manifest.version, prerelease: isPrerelease(manifest.version),
|
|
241
|
+
alreadyPublished: true,
|
|
242
|
+
};
|
|
243
|
+
}
|
|
244
|
+
throw new ChannelError(`the NuGet feed already has ${id} ${manifest.version}` +
|
|
245
|
+
(same === false ? ", and it is not this package" : ", and it cannot be read back to compare") +
|
|
246
|
+
"; a published version is never replaced, so publish the change as a new version");
|
|
247
|
+
}
|
|
248
|
+
if (!response.ok) {
|
|
249
|
+
throw new ChannelError(`the NuGet feed refused the package: ${response.status} ${response.statusText}. ` +
|
|
250
|
+
(response.status === 401 || response.status === 403
|
|
251
|
+
? "Check the key in the environment variable named by channels.nuget.tokenEnv." : ""));
|
|
252
|
+
}
|
|
253
|
+
log(`-- Pushed NuGet ${id} ${manifest.version} to ${endpoint}`);
|
|
254
|
+
return { location: endpoint, id, version: manifest.version, prerelease: isPrerelease(manifest.version) };
|
|
255
|
+
})();
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* The channel: a path writes into a local feed, an http(s) URL -- the feed's service
|
|
260
|
+
* index -- pushes to a remote one. Everything that can be refused is refused before
|
|
261
|
+
* anything is written or sent.
|
|
262
|
+
*/
|
|
263
|
+
function publishNuget(archive, manifest, options, log) {
|
|
264
|
+
const id = packageId(manifest.target, options);
|
|
265
|
+
checkVersion(manifest.version);
|
|
266
|
+
if (!options.repository) {
|
|
267
|
+
throw new ChannelError("the nuget channel requires a repository: a directory for a local feed, or a feed's service index URL");
|
|
268
|
+
}
|
|
269
|
+
if (/^https?:\/\//.test(options.repository)) {
|
|
270
|
+
return publishRemote(archive, manifest, options, id, log);
|
|
271
|
+
}
|
|
272
|
+
return publishLocal(archive, manifest, options, id, log);
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
module.exports = { publishNuget, packageId, nuspec, checkVersion, CONTENT_FOLDER };
|
package/src/pack.js
CHANGED
|
@@ -11,6 +11,8 @@ const crypto = require("crypto");
|
|
|
11
11
|
const { execFileSync } = require("child_process");
|
|
12
12
|
const YAML = require("yaml");
|
|
13
13
|
|
|
14
|
+
const { tgz } = require("./tar");
|
|
15
|
+
|
|
14
16
|
const MANIFEST_NAME = "manifest.json";
|
|
15
17
|
const MANIFEST_SCHEMA_VERSION = 1;
|
|
16
18
|
|
|
@@ -42,7 +44,31 @@ function gitInfo(cwd) {
|
|
|
42
44
|
* one version to differ by a line ending, which surfaces months later as an
|
|
43
45
|
* unexplainable verify failure.
|
|
44
46
|
*/
|
|
45
|
-
|
|
47
|
+
/**
|
|
48
|
+
* When the documents were produced: SOURCE_DATE_EPOCH when it is set -- the
|
|
49
|
+
* reproducible-builds convention, and the way to choose it -- otherwise the time of the
|
|
50
|
+
* commit being packed, and the current time only outside a git repository. Never simply
|
|
51
|
+
* "now" inside one: packing the same commit twice has to give the same manifest, and so
|
|
52
|
+
* the same archive, or no channel could publish the same version twice idempotently.
|
|
53
|
+
*/
|
|
54
|
+
function producedAtFor(cwd) {
|
|
55
|
+
const epoch = process.env.SOURCE_DATE_EPOCH;
|
|
56
|
+
if (epoch !== undefined && epoch !== "") {
|
|
57
|
+
if (!/^\d+$/.test(epoch)) {
|
|
58
|
+
throw new PackError(`SOURCE_DATE_EPOCH must be a whole number of seconds since 1970, not '${epoch}'`);
|
|
59
|
+
}
|
|
60
|
+
return new Date(Number(epoch) * 1000);
|
|
61
|
+
}
|
|
62
|
+
try {
|
|
63
|
+
const seconds = execFileSync("git", ["log", "-1", "--format=%ct"], { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim();
|
|
64
|
+
if (/^\d+$/.test(seconds)) return new Date(Number(seconds) * 1000);
|
|
65
|
+
} catch {
|
|
66
|
+
// Not a git repository, or one with no commits yet.
|
|
67
|
+
}
|
|
68
|
+
return new Date();
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function manifest(config, target, { version, files, closureSha256, producedAt = producedAtFor(config.root) }) {
|
|
46
72
|
if (!version) throw new PackError(`target '${target}': pack requires a version`);
|
|
47
73
|
|
|
48
74
|
const source = gitInfo(config.root);
|
|
@@ -111,13 +137,9 @@ function pack(config, target, { version, closureSha256, outDir, log = () => {} }
|
|
|
111
137
|
|
|
112
138
|
// Deterministic archive: sorted entry names, and no mtime/owner noise, so the
|
|
113
139
|
// same inputs produce the same bytes on any machine.
|
|
114
|
-
const entries = documents.map((d) => path.basename(d)).concat([MANIFEST_NAME]).sort()
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
"-C", distDir,
|
|
118
|
-
"--numeric-owner",
|
|
119
|
-
...entries,
|
|
120
|
-
], { encoding: "utf8" });
|
|
140
|
+
const entries = documents.map((d) => path.basename(d)).concat([MANIFEST_NAME]).sort()
|
|
141
|
+
.map((name) => ({ name, data: fs.readFileSync(path.join(distDir, name)) }));
|
|
142
|
+
fs.writeFileSync(archive, tgz(entries, Math.floor(Date.parse(data.producedAt) / 1000)));
|
|
121
143
|
|
|
122
144
|
log(`-- Packed ${path.basename(archive)} (${data.files.length} document(s))`);
|
|
123
145
|
return { archive, manifest: data, manifestPath };
|
package/src/pipeline.js
CHANGED
|
@@ -11,6 +11,9 @@ const { substituteFile } = require("./placeholders");
|
|
|
11
11
|
const { stampFile } = require("./version");
|
|
12
12
|
const { generateAsyncApi, generateOpenApi, openapiPushDown, isAggregate } = require("./aggregate");
|
|
13
13
|
const { stampFiles, componentPaths, strayPaths, fragmentStamps, unresolvedStamps, FragmentPathError, KEY } = require("./fragment-paths");
|
|
14
|
+
const {
|
|
15
|
+
asyncapiOperationsWithoutExamples, openapiOperationsWithoutExamples, asyncapiMessage, openapiMessage,
|
|
16
|
+
} = require("./examples");
|
|
14
17
|
|
|
15
18
|
class BuildError extends Error {}
|
|
16
19
|
|
|
@@ -205,6 +208,33 @@ function lint(config, kind, file, log, { report = false, reportFile = null } = {
|
|
|
205
208
|
if (report && output.trim()) log(output.trimEnd());
|
|
206
209
|
}
|
|
207
210
|
|
|
211
|
+
/**
|
|
212
|
+
* Reports operations of a built document whose example this kind's toolchain
|
|
213
|
+
* could use, and does not have -- separately from lint's own findings, since
|
|
214
|
+
* "your contract is wrong" and "your contract limits what you can do with it
|
|
215
|
+
* downstream" are different claims and read worse conflated.
|
|
216
|
+
*
|
|
217
|
+
* Governed by `lint.examples.<kind>`: `warn`, the default, logs each finding and
|
|
218
|
+
* continues; `error` logs them and fails the build; `off` does not look.
|
|
219
|
+
*/
|
|
220
|
+
function checkExamples(config, kind, file, log) {
|
|
221
|
+
const mode = config.lintExamples(kind);
|
|
222
|
+
if (mode === "off") return;
|
|
223
|
+
|
|
224
|
+
const document = YAML.parse(fs.readFileSync(file, "utf8"));
|
|
225
|
+
const findings = kind === "asyncapi"
|
|
226
|
+
? asyncapiOperationsWithoutExamples(document).map(asyncapiMessage)
|
|
227
|
+
: openapiOperationsWithoutExamples(document).map(openapiMessage);
|
|
228
|
+
if (findings.length === 0) return;
|
|
229
|
+
|
|
230
|
+
for (const message of findings) log(`-- ${message}`);
|
|
231
|
+
if (mode === "error") {
|
|
232
|
+
throw new BuildError(
|
|
233
|
+
`${findings.length} operation(s) in ${path.basename(file)} have no example ` +
|
|
234
|
+
"(lint.examples." + kind + " is error):\n" + findings.map((m) => ` ${m}`).join("\n"));
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
208
238
|
/**
|
|
209
239
|
* Copy a built document to the project that implements the target.
|
|
210
240
|
*
|
|
@@ -304,6 +334,7 @@ function build(config, { targets, versionOf = () => null, kinds = ["openapi", "a
|
|
|
304
334
|
stampFile(outFile, version);
|
|
305
335
|
}
|
|
306
336
|
lint(config, kind, outFile, log);
|
|
337
|
+
checkExamples(config, kind, outFile, log);
|
|
307
338
|
|
|
308
339
|
let distributed = null;
|
|
309
340
|
if (config.isPublished(target)) {
|
|
@@ -317,4 +348,6 @@ function build(config, { targets, versionOf = () => null, kinds = ["openapi", "a
|
|
|
317
348
|
return results;
|
|
318
349
|
}
|
|
319
350
|
|
|
320
|
-
module.exports = {
|
|
351
|
+
module.exports = {
|
|
352
|
+
build, prepare, stage, substituteTree, bundle, bundleWithFragmentPaths, lint, checkExamples, distribute, BuildError,
|
|
353
|
+
};
|
package/src/tar.js
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
// A reproducible .tgz writer, for the archive `pack` produces.
|
|
4
|
+
//
|
|
5
|
+
// The system tar records each file's modification time, owner and permissions, and gzip
|
|
6
|
+
// records when it ran, so packing the same documents twice gave two archives. GNU and BSD
|
|
7
|
+
// tar do not agree on the flags that would pin those, so the archive is written here: a
|
|
8
|
+
// ustar header per file, with a fixed mode, no owner, and a modification time the caller
|
|
9
|
+
// chooses, then gzip with no timestamp and a fixed operating-system byte.
|
|
10
|
+
|
|
11
|
+
const zlib = require("zlib");
|
|
12
|
+
|
|
13
|
+
class TarError extends Error {}
|
|
14
|
+
|
|
15
|
+
const BLOCK = 512;
|
|
16
|
+
|
|
17
|
+
function octal(value, length) {
|
|
18
|
+
return value.toString(8).padStart(length - 1, "0") + "\0";
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function header(name, size, mtime) {
|
|
22
|
+
const nameBytes = Buffer.from(name, "utf8");
|
|
23
|
+
if (nameBytes.length > 100 || name.includes("/") || name === "" || name === "." || name === "..") {
|
|
24
|
+
throw new TarError(`${JSON.stringify(name)} cannot be an entry of the archive: a plain file name of at most 100 bytes`);
|
|
25
|
+
}
|
|
26
|
+
const block = Buffer.alloc(BLOCK, 0);
|
|
27
|
+
nameBytes.copy(block, 0);
|
|
28
|
+
block.write(octal(0o644, 8), 100, "ascii");
|
|
29
|
+
block.write(octal(0, 8), 108, "ascii");
|
|
30
|
+
block.write(octal(0, 8), 116, "ascii");
|
|
31
|
+
block.write(octal(size, 12), 124, "ascii");
|
|
32
|
+
block.write(octal(mtime, 12), 136, "ascii");
|
|
33
|
+
block.write(" ", 148, "ascii");
|
|
34
|
+
block.write("0", 156, "ascii");
|
|
35
|
+
block.write("ustar\0", 257, "ascii");
|
|
36
|
+
block.write("00", 263, "ascii");
|
|
37
|
+
let sum = 0;
|
|
38
|
+
for (const byte of block) sum += byte;
|
|
39
|
+
block.write(octal(sum, 7) + " ", 148, "ascii");
|
|
40
|
+
return block;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* A gzipped ustar archive of `entries`, in the order given, every entry with mode 0644,
|
|
45
|
+
* owner 0 and modification time `mtime`. The same arguments always give the same bytes.
|
|
46
|
+
*
|
|
47
|
+
* @param {Array<{name: string, data: Buffer}>} entries plain file names and their bytes
|
|
48
|
+
* @param {number} mtime seconds since the epoch
|
|
49
|
+
* @returns {Buffer}
|
|
50
|
+
*/
|
|
51
|
+
function tgz(entries, mtime) {
|
|
52
|
+
const parts = [];
|
|
53
|
+
for (const { name, data } of entries) {
|
|
54
|
+
parts.push(header(name, data.length, mtime), data);
|
|
55
|
+
const padding = (BLOCK - (data.length % BLOCK)) % BLOCK;
|
|
56
|
+
if (padding) parts.push(Buffer.alloc(padding, 0));
|
|
57
|
+
}
|
|
58
|
+
parts.push(Buffer.alloc(BLOCK * 2, 0));
|
|
59
|
+
const gzipped = zlib.gzipSync(Buffer.concat(parts), { level: 9 });
|
|
60
|
+
// zlib writes the operating system it was built for into byte 9; 255 is "unknown",
|
|
61
|
+
// so an archive packed on Windows is the same as one packed on Linux.
|
|
62
|
+
gzipped[9] = 0xff;
|
|
63
|
+
return gzipped;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
module.exports = { tgz, TarError };
|
package/src/zip.js
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
// A deterministic ZIP writer, for the .nupkg the nuget channel builds.
|
|
4
|
+
//
|
|
5
|
+
// Node has no ZIP writer, and the Publisher carries one runtime dependency; zip, unlike
|
|
6
|
+
// tar, is not reliably installed on Windows or in slim CI images. The subset needed is
|
|
7
|
+
// small: a local header and deflated data per entry, a central directory, and an end
|
|
8
|
+
// record. No ZIP64, no encryption, no extra fields.
|
|
9
|
+
//
|
|
10
|
+
// Deterministic by construction, so that the same packed archive always yields the same
|
|
11
|
+
// package: entries are written in the order given, every entry carries the same fixed
|
|
12
|
+
// timestamp, and deflate runs at a fixed level.
|
|
13
|
+
|
|
14
|
+
const zlib = require("zlib");
|
|
15
|
+
|
|
16
|
+
class ZipError extends Error {}
|
|
17
|
+
|
|
18
|
+
// 1980-01-01 00:00:00, the earliest time a DOS date can hold.
|
|
19
|
+
const DOS_DATE = (0 << 9) | (1 << 5) | 1;
|
|
20
|
+
const DOS_TIME = 0;
|
|
21
|
+
const UTF8_NAMES = 0x0800;
|
|
22
|
+
|
|
23
|
+
const CRC_TABLE = Array.from({ length: 256 }, (_, n) => {
|
|
24
|
+
let c = n;
|
|
25
|
+
for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
|
|
26
|
+
return c >>> 0;
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
function crc32(buffer) {
|
|
30
|
+
let crc = 0xffffffff;
|
|
31
|
+
for (const byte of buffer) crc = CRC_TABLE[(crc ^ byte) & 0xff] ^ (crc >>> 8);
|
|
32
|
+
return (crc ^ 0xffffffff) >>> 0;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Refuses a name that is empty, absolute, climbs out, uses backslashes, or repeats. */
|
|
36
|
+
function checkNames(entries) {
|
|
37
|
+
const seen = new Set();
|
|
38
|
+
for (const { name } of entries) {
|
|
39
|
+
if (!name || name.startsWith("/") || name.includes("\\") || name.split("/").includes("..")) {
|
|
40
|
+
throw new ZipError(`${JSON.stringify(name)} is not a valid entry name`);
|
|
41
|
+
}
|
|
42
|
+
if (seen.has(name)) throw new ZipError(`${JSON.stringify(name)} appears twice`);
|
|
43
|
+
seen.add(name);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* A ZIP archive of `entries`, in the order given.
|
|
49
|
+
*
|
|
50
|
+
* @param {Array<{name: string, data: Buffer}>} entries forward-slash paths and their bytes
|
|
51
|
+
* @returns {Buffer}
|
|
52
|
+
*/
|
|
53
|
+
function zip(entries) {
|
|
54
|
+
checkNames(entries);
|
|
55
|
+
const locals = [];
|
|
56
|
+
const centrals = [];
|
|
57
|
+
let offset = 0;
|
|
58
|
+
for (const { name, data } of entries) {
|
|
59
|
+
const nameBytes = Buffer.from(name, "utf8");
|
|
60
|
+
const deflated = zlib.deflateRawSync(data, { level: 9 });
|
|
61
|
+
const crc = crc32(data);
|
|
62
|
+
|
|
63
|
+
const local = Buffer.alloc(30);
|
|
64
|
+
local.writeUInt32LE(0x04034b50, 0);
|
|
65
|
+
local.writeUInt16LE(20, 4);
|
|
66
|
+
local.writeUInt16LE(UTF8_NAMES, 6);
|
|
67
|
+
local.writeUInt16LE(8, 8);
|
|
68
|
+
local.writeUInt16LE(DOS_TIME, 10);
|
|
69
|
+
local.writeUInt16LE(DOS_DATE, 12);
|
|
70
|
+
local.writeUInt32LE(crc, 14);
|
|
71
|
+
local.writeUInt32LE(deflated.length, 18);
|
|
72
|
+
local.writeUInt32LE(data.length, 22);
|
|
73
|
+
local.writeUInt16LE(nameBytes.length, 26);
|
|
74
|
+
locals.push(local, nameBytes, deflated);
|
|
75
|
+
|
|
76
|
+
const central = Buffer.alloc(46);
|
|
77
|
+
central.writeUInt32LE(0x02014b50, 0);
|
|
78
|
+
central.writeUInt16LE(20, 4);
|
|
79
|
+
central.writeUInt16LE(20, 6);
|
|
80
|
+
central.writeUInt16LE(UTF8_NAMES, 8);
|
|
81
|
+
central.writeUInt16LE(8, 10);
|
|
82
|
+
central.writeUInt16LE(DOS_TIME, 12);
|
|
83
|
+
central.writeUInt16LE(DOS_DATE, 14);
|
|
84
|
+
central.writeUInt32LE(crc, 16);
|
|
85
|
+
central.writeUInt32LE(deflated.length, 20);
|
|
86
|
+
central.writeUInt32LE(data.length, 24);
|
|
87
|
+
central.writeUInt16LE(nameBytes.length, 28);
|
|
88
|
+
central.writeUInt32LE(offset, 42);
|
|
89
|
+
centrals.push(central, nameBytes);
|
|
90
|
+
|
|
91
|
+
offset += local.length + nameBytes.length + deflated.length;
|
|
92
|
+
}
|
|
93
|
+
const directory = Buffer.concat(centrals);
|
|
94
|
+
const end = Buffer.alloc(22);
|
|
95
|
+
end.writeUInt32LE(0x06054b50, 0);
|
|
96
|
+
end.writeUInt16LE(entries.length, 8);
|
|
97
|
+
end.writeUInt16LE(entries.length, 10);
|
|
98
|
+
end.writeUInt32LE(directory.length, 12);
|
|
99
|
+
end.writeUInt32LE(offset, 16);
|
|
100
|
+
return Buffer.concat([...locals, directory, end]);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
module.exports = { zip, crc32, ZipError };
|