@arc-e-tect/api-only-publisher 0.9.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 +101 -7
- package/package.json +1 -1
- package/src/channel-error.js +7 -0
- package/src/channels.js +90 -9
- package/src/nuget.js +275 -0
- package/src/pack.js +30 -8
- 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]
|
|
@@ -808,6 +900,8 @@ OpenAPI operation '<operationId>': <request body, or response <status>> (<fragme
|
|
|
808
900
|
A body or response with no `content` at all, such as a `204`, has nothing to exemplify and is never reported.
|
|
809
901
|
|
|
810
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.
|
|
811
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 →]).
|
|
812
906
|
|
|
813
907
|
[#version-stamping]
|
|
@@ -1034,7 +1128,7 @@ Anything else is a bug; please report it.
|
|
|
1034
1128
|
npm test
|
|
1035
1129
|
----
|
|
1036
1130
|
|
|
1037
|
-
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.
|
|
1038
1132
|
`build.e2e.test.js` runs the real bundlers.
|
|
1039
1133
|
The run fails below 90% of lines and functions, or 80% of branches.
|
|
1040
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/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/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 };
|