@arc-e-tect/api-only-publisher 0.16.1 → 0.17.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.
@@ -0,0 +1,158 @@
1
+ "use strict";
2
+
3
+ // A changed specification comes with a changed version.
4
+ //
5
+ // A subscriber locks a version's documents by their hashes, so a version published again
6
+ // with other documents breaks every build that already locked it. The registries refuse
7
+ // such a version, but only at publish time, only on remote channels, and only when they
8
+ // already hold it; this guard decides it from git alone, before anything is packed, so a
9
+ // pull request can fail on it.
10
+ //
11
+ // What a version was released from is known from the target's release tags, which the
12
+ // pipeline creates after it publishes (`versioning.tagFormat`); the Publisher only reads
13
+ // them. A target with no release tag yet is compared with the merge-base with the main
14
+ // branch, or with the ref `--since` names.
15
+
16
+ const fs = require("fs");
17
+ const path = require("path");
18
+ const { execFileSync } = require("child_process");
19
+
20
+ const { prepare } = require("./pipeline");
21
+ const { changedSince } = require("./changed");
22
+ const { versionOf, parseProperties } = require("./bundle-version");
23
+ const { compare, isPrerelease, SEMVER } = require("./version-policy");
24
+
25
+ class GuardError extends Error {}
26
+
27
+ function git(args, cwd) {
28
+ return execFileSync("git", args, { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }).trim();
29
+ }
30
+
31
+ function tryGit(args, cwd) {
32
+ try {
33
+ return git(args, cwd);
34
+ } catch {
35
+ return null;
36
+ }
37
+ }
38
+
39
+ function escape(text) {
40
+ return text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
41
+ }
42
+
43
+ /** The target's release tags, by version: every tag `tagFormat` gives for it with a semver version. */
44
+ function releaseTags(config, target, repoRoot) {
45
+ const [before, after] = config.tagFormat().replace(/\{target\}/g, target).split("{version}");
46
+ const pattern = new RegExp(`^${escape(before)}(.+)${escape(after)}$`);
47
+ const tags = new Map();
48
+ for (const tag of git(["tag", "--list"], repoRoot).split("\n")) {
49
+ const match = pattern.exec(tag);
50
+ if (match && SEMVER.test(match[1])) tags.set(match[1], tag);
51
+ }
52
+ return tags;
53
+ }
54
+
55
+ /**
56
+ * The ref a target without release tags is compared with, and how to name it; or, when
57
+ * there is no merge-base -- a shallow checkout, or no main branch -- why not.
58
+ */
59
+ function fallbackBaseline(config, repoRoot, since) {
60
+ if (since) {
61
+ const commit = tryGit(["rev-parse", "--verify", "--quiet", `${since}^{commit}`], repoRoot);
62
+ if (!commit) throw new GuardError(`--since ${since} is not a commit in this repository`);
63
+ return { ref: commit, label: `${since} (${commit.slice(0, 12)})` };
64
+ }
65
+ const branch = config.mainBranch();
66
+ for (const candidate of [`origin/${branch}`, branch]) {
67
+ if (!tryGit(["rev-parse", "--verify", "--quiet", `${candidate}^{commit}`], repoRoot)) continue;
68
+ const base = tryGit(["merge-base", "HEAD", candidate], repoRoot);
69
+ if (base) return { ref: base, label: `the merge-base with ${branch} (${base.slice(0, 12)})` };
70
+ }
71
+ return { missing: `no merge-base with ${branch}, the branch versioning.mainBranch names, to compare a target without a release tag with. ` +
72
+ "In CI, fetch the full history and the branch (actions/checkout with fetch-depth: 0, or GIT_DEPTH: 0 on GitLab), " +
73
+ "or name the ref to compare with: --since <ref>." };
74
+ }
75
+
76
+ /** The version a target's version file -- `rel`, from the repository root -- declared at `ref`, or null when it was not there. */
77
+ function versionAt(ref, rel, repoRoot) {
78
+ const text = tryGit(["show", `${ref}:${rel}`], repoRoot);
79
+ return text === null ? null : parseProperties(text).version || null;
80
+ }
81
+
82
+ /**
83
+ * Whether each target's version may be published: a version released before, from other
84
+ * specifications, may not; nor one lower than what came before it.
85
+ *
86
+ * @param {object} config a loaded configuration
87
+ * @param {string[]} targets the targets to judge; each needs a version file
88
+ * @param {{preRelease?: string|null, since?: string|null}} options
89
+ * A target the tags do not decide, with no merge-base to compare with, is `undecided`.
90
+ *
91
+ * @returns {Array<{target: string, version: string, verdict: "ok"|"violation"|"undecided", reason: string}>}
92
+ */
93
+ function guard(config, targets, { preRelease = null, since = null } = {}) {
94
+ const top = tryGit(["rev-parse", "--show-toplevel"], config.root);
95
+ if (top === null) {
96
+ const error = new GuardError(`${config.root} is not in a git repository, so whether a changed target has a ` +
97
+ "changed version cannot be decided: the guard reads release tags and history from git");
98
+ error.notARepository = true;
99
+ throw error;
100
+ }
101
+ // Real paths on both sides: git answers with the canonical one, and a checkout reached
102
+ // through a symlink -- /var on macOS -- would otherwise never match.
103
+ const repoRoot = fs.realpathSync(top);
104
+ prepare(config);
105
+
106
+ const changes = new Map();
107
+ const changedSinceRef = (ref, target) => {
108
+ if (!changes.has(ref)) {
109
+ changes.set(ref, new Map(changedSince(config, ref).map((r) => [r.target, r.changed])));
110
+ }
111
+ return changes.get(ref).get(target) === true;
112
+ };
113
+ let fallback = null;
114
+
115
+ return targets.map((target) => {
116
+ const declared = versionOf(config, target);
117
+ const version = versionOf(config, target, { preRelease });
118
+ const file = path.relative(repoRoot, fs.realpathSync(config.versionFile(target))).split(path.sep).join("/");
119
+ const ok = (reason) => ({ target, version, verdict: "ok", reason });
120
+ const violation = (reason) => ({ target, version, verdict: "violation", reason });
121
+ const tags = releaseTags(config, target, repoRoot);
122
+
123
+ // Released before: exact, whatever else is true.
124
+ if (tags.has(version)) {
125
+ const tag = tags.get(version);
126
+ return changedSinceRef(tag, target)
127
+ ? violation(`${version} was released as ${tag}, and ${target} has changed since: raise the version in ${file}`)
128
+ : ok(`unchanged since its release tag ${tag}`);
129
+ }
130
+ if (preRelease && tags.has(declared)) {
131
+ return violation(`${version} is a pre-release of ${declared}, which is already released as ${tags.get(declared)}: ` +
132
+ `it would sort before the release; declare the next version in ${file}`);
133
+ }
134
+ const releases = [...tags.keys()].filter((v) => !isPrerelease(v)).sort(compare);
135
+ if (releases.length > 0) {
136
+ const latest = releases[releases.length - 1];
137
+ return compare(declared, latest) < 0
138
+ ? violation(`${declared} is lower than its latest release, ${latest} (${tags.get(latest)}): raise the version in ${file}`)
139
+ : ok(`${declared} is above its latest release, ${latest}`);
140
+ }
141
+
142
+ // Never released, as far as the tags tell: the merge-base, or --since, decides.
143
+ fallback = fallback || fallbackBaseline(config, repoRoot, since);
144
+ if (fallback.missing) return { target, version, verdict: "undecided", reason: fallback.missing };
145
+ const before = versionAt(fallback.ref, file, repoRoot);
146
+ if (before === null) return ok(`new: no version file at ${fallback.label}`);
147
+ if (compare(declared, before) < 0) {
148
+ return violation(`${declared} is lower than ${before} at ${fallback.label}: raise the version in ${file}`);
149
+ }
150
+ if (!changedSinceRef(fallback.ref, target)) return ok(`unchanged since ${fallback.label}`);
151
+ if (compare(declared, before) > 0) return ok(`${before} -> ${declared} since ${fallback.label}`);
152
+ if (preRelease) return ok(`pre-release ${version}: its identifiers make it a new version`);
153
+ return violation(`${target} changed since ${fallback.label}, but its version is still ${declared}: ` +
154
+ `raise it in ${file}`);
155
+ });
156
+ }
157
+
158
+ module.exports = { guard, releaseTags, GuardError };
package/src/zip.js CHANGED
@@ -100,4 +100,45 @@ function zip(entries) {
100
100
  return Buffer.concat([...locals, directory, end]);
101
101
  }
102
102
 
103
- module.exports = { zip, crc32, ZipError };
103
+ /**
104
+ * The entries of a ZIP archive, from its central directory, each inflated and checked
105
+ * against its CRC-32: what `zip` writes, and what a NuGet feed returns of it.
106
+ *
107
+ * @param {Buffer} bytes
108
+ * @returns {Array<{name: string, data: Buffer}>}
109
+ */
110
+ function unzip(bytes) {
111
+ const end = bytes.lastIndexOf(Buffer.from([0x50, 0x4b, 0x05, 0x06]));
112
+ if (end < 0 || end + 22 > bytes.length) throw new ZipError("not a ZIP archive: it has no end of central directory record");
113
+ const entries = [];
114
+ let offset = bytes.readUInt32LE(end + 16);
115
+ for (let i = 0; i < bytes.readUInt16LE(end + 10); i++) {
116
+ if (offset + 46 > bytes.length || bytes.readUInt32LE(offset) !== 0x02014b50) {
117
+ throw new ZipError(`not a ZIP archive: no central directory entry at ${offset}`);
118
+ }
119
+ const method = bytes.readUInt16LE(offset + 10);
120
+ const crc = bytes.readUInt32LE(offset + 16);
121
+ const compressed = bytes.readUInt32LE(offset + 20);
122
+ const nameLength = bytes.readUInt16LE(offset + 28);
123
+ const local = bytes.readUInt32LE(offset + 42);
124
+ const name = bytes.toString("utf8", offset + 46, offset + 46 + nameLength);
125
+ if (local + 30 > bytes.length || bytes.readUInt32LE(local) !== 0x04034b50) {
126
+ throw new ZipError(`not a ZIP archive: no local header for ${name}`);
127
+ }
128
+ const start = local + 30 + bytes.readUInt16LE(local + 26) + bytes.readUInt16LE(local + 28);
129
+ const raw = bytes.subarray(start, start + compressed);
130
+ let data;
131
+ try {
132
+ data = method === 8 ? zlib.inflateRawSync(raw) : method === 0 ? Buffer.from(raw) : null;
133
+ } catch (error) {
134
+ throw new ZipError(`${name} cannot be inflated: ${error.message}`);
135
+ }
136
+ if (data === null) throw new ZipError(`${name} is compressed with method ${method}, which is not supported`);
137
+ if (crc32(data) !== crc) throw new ZipError(`${name} does not match its CRC-32`);
138
+ entries.push({ name, data });
139
+ offset += 46 + nameLength + bytes.readUInt16LE(offset + 30) + bytes.readUInt16LE(offset + 32);
140
+ }
141
+ return entries;
142
+ }
143
+
144
+ module.exports = { zip, unzip, crc32, ZipError };