Skip to content

Commit 1f69eed

Browse files
authored
Harden Public Preview and alpha bootstrap gates (#8)
Aggregate standalone-content validation into the Public Preview gate, align environment reviewer guidance, and encode the protected alpha.1-only npm bootstrap path.
1 parent f887784 commit 1f69eed

14 files changed

Lines changed: 329 additions & 155 deletions

.github/workflows/publish.yml

Lines changed: 17 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -150,8 +150,8 @@ jobs:
150150
cancel-in-progress: false
151151
runs-on: ubuntu-latest
152152
timeout-minutes: 5
153-
# Required repository configuration: configure the protected live-smoke environment,
154-
# required reviewers, and COMETAPI_KEY before publishing a release.
153+
# Required repository configuration: configure the protected live-smoke environment
154+
# without required reviewers and add COMETAPI_KEY before publishing a release.
155155
environment: live-smoke
156156
steps:
157157
- name: Check out the verified release tag
@@ -180,14 +180,14 @@ jobs:
180180
run: npm run test:live
181181

182182
publish:
183-
name: Publish with npm Trusted Publishing
183+
name: Publish with npm Trusted Publishing or alpha.1 bootstrap
184184
needs:
185185
- live-smoke
186186
- verify
187187
runs-on: ubuntu-latest
188188
timeout-minutes: 15
189-
# Required repository configuration: configure the protected npm environment, required
190-
# reviewers, package ownership, and this workflow as the Trusted Publisher.
189+
# Required repository configuration: configure the protected npm environment with
190+
# approval by the current release approver and self-review allowed, plus package ownership.
191191
environment:
192192
name: npm
193193
url: https://www.npmjs.com/package/cometapi/v/${{ needs.verify.outputs.version }}
@@ -209,11 +209,19 @@ jobs:
209209
path: release-artifacts
210210
- name: Publish the exact artifact with provenance
211211
env:
212+
ALPHA1_BOOTSTRAP_ENABLED: ${{ vars.NPM_ALPHA1_BOOTSTRAP_ENABLED }}
212213
DIST_TAG: ${{ needs.verify.outputs.dist-tag }}
214+
NODE_AUTH_TOKEN: ${{ vars.NPM_ALPHA1_BOOTSTRAP_ENABLED == 'true' && needs.verify.outputs.version == '0.1.0-alpha.1' && secrets.NPM_ALPHA1_BOOTSTRAP_TOKEN || '' }}
213215
VERSION: ${{ needs.verify.outputs.version }}
214216
shell: bash
215217
run: |
216218
set -euo pipefail
219+
if [[ "$ALPHA1_BOOTSTRAP_ENABLED" == "true" && \
220+
( "$VERSION" != "0.1.0-alpha.1" || "$DIST_TAG" != "next" ) ]]; then
221+
echo "The token bootstrap is restricted to cometapi@0.1.0-alpha.1 on the next dist-tag." >&2
222+
exit 1
223+
fi
224+
217225
mapfile -t tarballs < <(find release-artifacts -maxdepth 1 -type f -name '*.tgz' -print)
218226
if [[ "${#tarballs[@]}" -ne 1 ]]; then
219227
echo "Expected exactly one downloaded artifact, found ${#tarballs[@]}." >&2
@@ -235,6 +243,10 @@ jobs:
235243
EOF
236244
echo "cometapi@${VERSION} already matches the verified artifact; resuming checks."
237245
elif grep -q "E404" "$view_error"; then
246+
if [[ "$ALPHA1_BOOTSTRAP_ENABLED" == "true" && -z "$NODE_AUTH_TOKEN" ]]; then
247+
echo "NPM_ALPHA1_BOOTSTRAP_TOKEN is required when the alpha.1 bootstrap is enabled." >&2
248+
exit 1
249+
fi
238250
npm publish "${tarballs[0]}" --access public --provenance --tag "$DIST_TAG"
239251
else
240252
echo "Unable to determine whether cometapi@${VERSION} already exists." >&2
@@ -363,6 +375,3 @@ jobs:
363375
process.exitCode = 1;
364376
});
365377
EOF
366-
367-
# The conditional first-publication token bootstrap is intentionally
368-
# not encoded here. It is an owner-run, one-time exception only.

AGENTS.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -176,6 +176,7 @@ npm run format:check
176176
npm run test:package
177177
npm run test:fixtures
178178
npm run test:compat
179+
npm run check:standalone-content
179180
npm run check:self-contained
180181
npm run actionlint
181182
npm run verify

ARCHITECTURE.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,9 @@ tag and GitHub release agreement.
8787
The publish workflow is the sole source of npm dist-tag selection: prereleases
8888
use `next`, stable versions use `latest`. The package manifest must not carry a
8989
static dist-tag because that would make stable and prerelease policy diverge.
90+
Trusted Publishing remains the default authentication path. The only token
91+
path is an explicitly enabled protected-environment fallback that rejects every
92+
version except `0.1.0-alpha.1` and every dist-tag except `next`.
9093

9194
## Testing layers
9295

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,8 @@ No user-visible changes are currently recorded beyond the initial alpha scope.
3232
- Made Public Preview and publication validation fail closed on canonical
3333
identity, public-safe standalone content, and release-ready status at the
3434
applicable gate.
35+
- Added standalone-content scanning to the aggregated Public Preview gate and
36+
encoded the protected, opt-in npm token bootstrap for `0.1.0-alpha.1` only.
3537
- Made the release workflow the sole npm dist-tag source: prereleases use
3638
`next`, stable releases use `latest`, and the package manifest has no static
3739
dist-tag.

RELEASING.md

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,7 @@ npm run test:package
9696
npm run test:live-contract
9797
npm run test:fixtures
9898
npm run test:compat
99+
npm run check:standalone-content
99100
npm run check:self-contained
100101
npm run actionlint
101102
npm run verify
@@ -290,14 +291,17 @@ Maintainers must first attempt normal Trusted Publisher configuration. If npm
290291
does not permit it before the package exists, only `0.1.0-alpha.1` may use this
291292
one-time exception:
292293

293-
1. A maintainer creates a short-lived granular read/write token with minimum scope
294-
and non-interactive 2FA bypass from an account protected by 2FA.
295-
2. The token is exposed only through a protected GitHub Environment to one
296-
reviewed immutable-tag run on a GitHub-hosted runner.
294+
1. A maintainer creates a short-lived granular read/write token with minimum
295+
scope and non-interactive 2FA bypass from an account protected by 2FA.
296+
2. In the protected `npm` environment, set
297+
`NPM_ALPHA1_BOOTSTRAP_ENABLED=true` and expose the token only as
298+
`NPM_ALPHA1_BOOTSTRAP_TOKEN` to one reviewed immutable-tag run. The workflow
299+
rejects the bootstrap for every version except `0.1.0-alpha.1`, requires the
300+
`next` dist-tag, and fails if the token is absent when publication is needed.
297301
3. That run verifies and publishes the exact artifact with public access and
298302
provenance, then verifies registry installation.
299-
4. A maintainer immediately configures OIDC, removes the environment secret,
300-
revokes the token, and restricts token-based publishing.
303+
4. A maintainer immediately configures OIDC, removes the environment variable
304+
and secret, revokes the token, and restricts token-based publishing.
301305
5. The project immediately prepares and publishes `0.1.0-alpha.2` through
302306
OIDC, verifies its provenance and public installation, and confirms that
303307
`next` resolves to `0.1.0-alpha.2`.

ROADMAP.md

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -320,15 +320,18 @@ Maintainers must first attempt to configure the Trusted Publisher normally. If
320320
npm does not allow configuration before the first package publication, only
321321
`0.1.0-alpha.1` may use a one-time bootstrap:
322322

323-
1. A maintainer creates a short-lived granular read/write publish token with the
324-
minimum available scope and non-interactive 2FA bypass from an account
323+
1. A maintainer creates a short-lived granular read/write publish token with
324+
the minimum available scope and non-interactive 2FA bypass from an account
325325
protected by 2FA.
326-
2. The token is exposed only through a protected GitHub Environment to one
327-
reviewed immutable-tag run on a GitHub-hosted runner.
326+
2. In the protected `npm` environment, set
327+
`NPM_ALPHA1_BOOTSTRAP_ENABLED=true` and expose the token only as
328+
`NPM_ALPHA1_BOOTSTRAP_TOKEN` to one reviewed immutable-tag run. The workflow
329+
rejects that mode unless the version is exactly `0.1.0-alpha.1` and the
330+
dist-tag is `next`.
328331
3. That run builds and verifies the tag artifact, publishes it with public
329332
access and provenance, and verifies registry installation.
330-
4. A maintainer immediately configures Trusted Publishing, removes the secret,
331-
revokes the token, and restricts token-based publishing.
333+
4. A maintainer immediately configures Trusted Publishing, removes the variable
334+
and secret, revokes the token, and restricts token-based publishing.
332335
5. The project immediately publishes `0.1.0-alpha.2` through OIDC, verifies its
333336
provenance and installation, and confirms that `next` resolves to
334337
`0.1.0-alpha.2`.

SECURITY.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,4 +59,5 @@ environment, provenance, an immutable reviewed tag, and post-publication
5959
installation verification. Long-lived registry tokens are forbidden. The sole
6060
conditional bootstrap for the first alpha is documented in
6161
[RELEASING.md](./RELEASING.md) and is owner-controlled, one-time, and immediately
62-
revoked.
62+
revoked. The workflow keeps this fallback disabled by default and rejects it
63+
for every version other than `0.1.0-alpha.1`.

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,7 @@
6565
"test:fixtures": "node scripts/test-fixtures.mjs",
6666
"test:compat": "node scripts/test-compat.mjs",
6767
"test:secrets": "node scripts/check-secrets.mjs",
68+
"check:standalone-content": "node scripts/check-standalone-content.mjs",
6869
"check:self-contained": "node scripts/check-self-contained.mjs",
6970
"check:public-preview": "node scripts/check-public-preview.mjs",
7071
"actionlint": "node scripts/run-actionlint.mjs",

scripts/check-public-preview.mjs

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import {
66
collectPublicPreviewViolations,
77
formatPublicPreviewViolations,
88
} from "./release-validation.mjs";
9+
import { collectStandaloneContentViolations } from "./standalone-content.mjs";
910

1011
const inputViolations = [];
1112
const read = (name) => {
@@ -47,7 +48,21 @@ const violations = collectPublicPreviewViolations({
4748
sourceManifest,
4849
});
4950

50-
const allViolations = [...inputViolations, ...violations];
51+
const standaloneContentViolations = [];
52+
try {
53+
standaloneContentViolations.push(...collectStandaloneContentViolations(ROOT));
54+
} catch (error) {
55+
const detail = error instanceof Error ? error.message : String(error);
56+
standaloneContentViolations.push(
57+
`standalone content could not be checked: ${detail}`,
58+
);
59+
}
60+
61+
const allViolations = [
62+
...inputViolations,
63+
...violations,
64+
...standaloneContentViolations,
65+
];
5166
if (allViolations.length > 0) {
5267
console.error(formatPublicPreviewViolations(allViolations));
5368
process.exitCode = 1;

scripts/check-self-contained.mjs

Lines changed: 8 additions & 133 deletions
Original file line numberDiff line numberDiff line change
@@ -1,152 +1,27 @@
1-
import { isUtf8 } from "node:buffer";
2-
import {
3-
cpSync,
4-
existsSync,
5-
readFileSync,
6-
readlinkSync,
7-
readdirSync,
8-
statSync,
9-
} from "node:fs";
10-
import {
11-
basename,
12-
dirname,
13-
isAbsolute,
14-
join,
15-
relative,
16-
resolve,
17-
sep,
18-
} from "node:path";
1+
import { cpSync } from "node:fs";
2+
import { basename, join } from "node:path";
193

204
import {
215
ROOT,
226
makeTemporaryDirectory,
237
removeTemporaryDirectory,
248
run,
259
} from "./lib.mjs";
26-
27-
const excluded = new Set([
28-
".artifacts",
29-
".cache",
30-
".DS_Store",
31-
".git",
32-
"coverage",
33-
"dist",
34-
"node_modules",
35-
]);
36-
const parentReferencePattern =
37-
/(?:^|[\s`"'(=:[{])((?:\.\.[\\/])+[A-Za-z0-9@%_+.,~\\/-]+)/gm;
38-
const absoluteLocalPathPatterns = [
39-
/(?:^|[\s`"'(=:[{])((?:file:\/\/\/(?:Users|home|private|root|Volumes|workspaces?)|\/(?:Users|home|private|root|Volumes|workspaces?))\/[A-Za-z0-9@%_+.,~/-]+)/gm,
40-
/(?:^|[\s`"'(=:[{])((?:~\/|\$HOME\/|\$\{HOME\}\/)[A-Za-z0-9@%_+.,~/-]+)/gm,
41-
/(?:^|[\s`"'(=:[{])([A-Za-z]:[\\/][^\s`"')\]}>;,]+)/gm,
42-
/(?:^|[\s`"'(=:[{])(\\\\[A-Za-z0-9._-]+[\\/][^\s`"')\]}>;,]+)/gm,
43-
];
44-
const privateArtifactPattern = /\b(?:SDK_PRD\.md|references\/)/g;
45-
const privateWorkspacePathPattern =
46-
/\b(?:cometapi-worksapce|cometapi-(?:python|go|cli)|comet-api-(?:backend|frontend|next))[\\/][A-Za-z0-9@%_+.~/-]{2,}/g;
47-
const privateReferencesDirectory = ["references", ""].join("/");
10+
import {
11+
collectStandaloneContentViolations,
12+
STANDALONE_CONTENT_EXCLUSIONS,
13+
} from "./standalone-content.mjs";
4814

4915
function shouldCopy(source) {
50-
return !excluded.has(basename(source));
51-
}
52-
53-
function isInside(root, path) {
54-
const pathFromRoot = relative(root, path);
55-
return (
56-
pathFromRoot === "" ||
57-
(!isAbsolute(pathFromRoot) &&
58-
pathFromRoot !== ".." &&
59-
!pathFromRoot.startsWith(`..${sep}`))
60-
);
61-
}
62-
63-
function scanTextFile(path, candidateRoot, violations) {
64-
const bytes = readFileSync(path);
65-
if (bytes.includes(0) || !isUtf8(bytes)) return;
66-
67-
const contents = bytes.toString("utf8");
68-
const displayPath = relative(candidateRoot, path);
69-
70-
for (const match of contents.matchAll(parentReferencePattern)) {
71-
const reference = match[1];
72-
const resolvedReference = resolve(
73-
dirname(path),
74-
reference.replaceAll("\\", "/"),
75-
);
76-
if (!isInside(candidateRoot, resolvedReference)) {
77-
violations.push(
78-
`${displayPath}: parent-relative path escapes the repository (${reference})`,
79-
);
80-
}
81-
}
82-
83-
for (const pattern of absoluteLocalPathPatterns) {
84-
for (const match of contents.matchAll(pattern)) {
85-
violations.push(
86-
`${displayPath}: absolute machine-local path is not standalone (${match[1]})`,
87-
);
88-
}
89-
}
90-
91-
for (const match of contents.matchAll(privateArtifactPattern)) {
92-
const reference = match[0];
93-
const rootEntry = reference.startsWith(privateReferencesDirectory)
94-
? "references"
95-
: reference;
96-
if (!existsSync(join(candidateRoot, rootEntry))) {
97-
violations.push(
98-
`${displayPath}: references non-repository private material (${reference})`,
99-
);
100-
}
101-
}
102-
103-
for (const match of contents.matchAll(privateWorkspacePathPattern)) {
104-
const reference = match[0];
105-
const rootEntry = reference.split(/[\\/]/, 1)[0];
106-
if (!existsSync(join(candidateRoot, rootEntry))) {
107-
violations.push(
108-
`${displayPath}: references a private workspace or sibling repository (${reference})`,
109-
);
110-
}
111-
}
112-
}
113-
114-
function scan(directory, candidateRoot, violations) {
115-
for (const entry of readdirSync(directory, { withFileTypes: true })) {
116-
if (entry.isDirectory() && excluded.has(entry.name)) continue;
117-
const path = join(directory, entry.name);
118-
if (entry.isDirectory()) {
119-
scan(path, candidateRoot, violations);
120-
continue;
121-
}
122-
if (entry.isSymbolicLink()) {
123-
const target = readlinkSync(path);
124-
const resolvedTarget = resolve(dirname(path), target);
125-
if (!isInside(candidateRoot, resolvedTarget)) {
126-
violations.push(
127-
`${relative(candidateRoot, path)}: symbolic link escapes the repository (${target})`,
128-
);
129-
continue;
130-
}
131-
if (!existsSync(resolvedTarget)) {
132-
violations.push(
133-
`${relative(candidateRoot, path)}: symbolic link target is missing (${target})`,
134-
);
135-
continue;
136-
}
137-
if (statSync(path).isDirectory()) continue;
138-
}
139-
scanTextFile(path, candidateRoot, violations);
140-
}
16+
return !STANDALONE_CONTENT_EXCLUSIONS.has(basename(source));
14117
}
14218

14319
const temporaryParent = makeTemporaryDirectory("cometapi-standalone-");
14420
const candidateRoot = join(temporaryParent, "cometapi-node");
14521

14622
try {
14723
cpSync(ROOT, candidateRoot, { filter: shouldCopy, recursive: true });
148-
const violations = [];
149-
scan(candidateRoot, candidateRoot, violations);
24+
const violations = collectStandaloneContentViolations(candidateRoot);
15025
if (violations.length > 0) {
15126
throw new Error(
15227
`Standalone repository scan found ${String(violations.length)} outside-root reference(s):\n- ${violations.join("\n- ")}`,

0 commit comments

Comments
 (0)