Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 17 additions & 8 deletions .github/workflows/publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -150,8 +150,8 @@ jobs:
cancel-in-progress: false
runs-on: ubuntu-latest
timeout-minutes: 5
# Required repository configuration: configure the protected live-smoke environment,
# required reviewers, and COMETAPI_KEY before publishing a release.
# Required repository configuration: configure the protected live-smoke environment
# without required reviewers and add COMETAPI_KEY before publishing a release.
environment: live-smoke
steps:
- name: Check out the verified release tag
Expand Down Expand Up @@ -180,14 +180,14 @@ jobs:
run: npm run test:live

publish:
name: Publish with npm Trusted Publishing
name: Publish with npm Trusted Publishing or alpha.1 bootstrap
needs:
- live-smoke
- verify
runs-on: ubuntu-latest
timeout-minutes: 15
# Required repository configuration: configure the protected npm environment, required
# reviewers, package ownership, and this workflow as the Trusted Publisher.
# Required repository configuration: configure the protected npm environment with
# approval by the current release approver and self-review allowed, plus package ownership.
environment:
name: npm
url: https://www.npmjs.com/package/cometapi/v/${{ needs.verify.outputs.version }}
Expand All @@ -209,11 +209,19 @@ jobs:
path: release-artifacts
- name: Publish the exact artifact with provenance
env:
ALPHA1_BOOTSTRAP_ENABLED: ${{ vars.NPM_ALPHA1_BOOTSTRAP_ENABLED }}
DIST_TAG: ${{ needs.verify.outputs.dist-tag }}
NODE_AUTH_TOKEN: ${{ vars.NPM_ALPHA1_BOOTSTRAP_ENABLED == 'true' && needs.verify.outputs.version == '0.1.0-alpha.1' && secrets.NPM_ALPHA1_BOOTSTRAP_TOKEN || '' }}
VERSION: ${{ needs.verify.outputs.version }}
shell: bash
run: |
set -euo pipefail
if [[ "$ALPHA1_BOOTSTRAP_ENABLED" == "true" && \
( "$VERSION" != "0.1.0-alpha.1" || "$DIST_TAG" != "next" ) ]]; then
echo "The token bootstrap is restricted to cometapi@0.1.0-alpha.1 on the next dist-tag." >&2
exit 1
fi

mapfile -t tarballs < <(find release-artifacts -maxdepth 1 -type f -name '*.tgz' -print)
if [[ "${#tarballs[@]}" -ne 1 ]]; then
echo "Expected exactly one downloaded artifact, found ${#tarballs[@]}." >&2
Expand All @@ -235,6 +243,10 @@ jobs:
EOF
echo "cometapi@${VERSION} already matches the verified artifact; resuming checks."
elif grep -q "E404" "$view_error"; then
if [[ "$ALPHA1_BOOTSTRAP_ENABLED" == "true" && -z "$NODE_AUTH_TOKEN" ]]; then
echo "NPM_ALPHA1_BOOTSTRAP_TOKEN is required when the alpha.1 bootstrap is enabled." >&2
exit 1
fi
npm publish "${tarballs[0]}" --access public --provenance --tag "$DIST_TAG"
else
echo "Unable to determine whether cometapi@${VERSION} already exists." >&2
Expand Down Expand Up @@ -363,6 +375,3 @@ jobs:
process.exitCode = 1;
});
EOF

# The conditional first-publication token bootstrap is intentionally
# not encoded here. It is an owner-run, one-time exception only.
1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -176,6 +176,7 @@ npm run format:check
npm run test:package
npm run test:fixtures
npm run test:compat
npm run check:standalone-content
npm run check:self-contained
npm run actionlint
npm run verify
Expand Down
3 changes: 3 additions & 0 deletions ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,9 @@ tag and GitHub release agreement.
The publish workflow is the sole source of npm dist-tag selection: prereleases
use `next`, stable versions use `latest`. The package manifest must not carry a
static dist-tag because that would make stable and prerelease policy diverge.
Trusted Publishing remains the default authentication path. The only token
path is an explicitly enabled protected-environment fallback that rejects every
version except `0.1.0-alpha.1` and every dist-tag except `next`.

## Testing layers

Expand Down
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,8 @@ No user-visible changes are currently recorded beyond the initial alpha scope.
- Made Public Preview and publication validation fail closed on canonical
identity, public-safe standalone content, and release-ready status at the
applicable gate.
- Added standalone-content scanning to the aggregated Public Preview gate and
encoded the protected, opt-in npm token bootstrap for `0.1.0-alpha.1` only.
- Made the release workflow the sole npm dist-tag source: prereleases use
`next`, stable releases use `latest`, and the package manifest has no static
dist-tag.
Expand Down
16 changes: 10 additions & 6 deletions RELEASING.md
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,7 @@ npm run test:package
npm run test:live-contract
npm run test:fixtures
npm run test:compat
npm run check:standalone-content
npm run check:self-contained
npm run actionlint
npm run verify
Expand Down Expand Up @@ -290,14 +291,17 @@ Maintainers must first attempt normal Trusted Publisher configuration. If npm
does not permit it before the package exists, only `0.1.0-alpha.1` may use this
one-time exception:

1. A maintainer creates a short-lived granular read/write token with minimum scope
and non-interactive 2FA bypass from an account protected by 2FA.
2. The token is exposed only through a protected GitHub Environment to one
reviewed immutable-tag run on a GitHub-hosted runner.
1. A maintainer creates a short-lived granular read/write token with minimum
scope and non-interactive 2FA bypass from an account protected by 2FA.
2. In the protected `npm` environment, set
`NPM_ALPHA1_BOOTSTRAP_ENABLED=true` and expose the token only as
`NPM_ALPHA1_BOOTSTRAP_TOKEN` to one reviewed immutable-tag run. The workflow
rejects the bootstrap for every version except `0.1.0-alpha.1`, requires the
`next` dist-tag, and fails if the token is absent when publication is needed.
3. That run verifies and publishes the exact artifact with public access and
provenance, then verifies registry installation.
4. A maintainer immediately configures OIDC, removes the environment secret,
revokes the token, and restricts token-based publishing.
4. A maintainer immediately configures OIDC, removes the environment variable
and secret, revokes the token, and restricts token-based publishing.
5. The project immediately prepares and publishes `0.1.0-alpha.2` through
OIDC, verifies its provenance and public installation, and confirms that
`next` resolves to `0.1.0-alpha.2`.
Expand Down
15 changes: 9 additions & 6 deletions ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -320,15 +320,18 @@ Maintainers must first attempt to configure the Trusted Publisher normally. If
npm does not allow configuration before the first package publication, only
`0.1.0-alpha.1` may use a one-time bootstrap:

1. A maintainer creates a short-lived granular read/write publish token with the
minimum available scope and non-interactive 2FA bypass from an account
1. A maintainer creates a short-lived granular read/write publish token with
the minimum available scope and non-interactive 2FA bypass from an account
protected by 2FA.
2. The token is exposed only through a protected GitHub Environment to one
reviewed immutable-tag run on a GitHub-hosted runner.
2. In the protected `npm` environment, set
`NPM_ALPHA1_BOOTSTRAP_ENABLED=true` and expose the token only as
`NPM_ALPHA1_BOOTSTRAP_TOKEN` to one reviewed immutable-tag run. The workflow
rejects that mode unless the version is exactly `0.1.0-alpha.1` and the
dist-tag is `next`.
3. That run builds and verifies the tag artifact, publishes it with public
access and provenance, and verifies registry installation.
4. A maintainer immediately configures Trusted Publishing, removes the secret,
revokes the token, and restricts token-based publishing.
4. A maintainer immediately configures Trusted Publishing, removes the variable
and secret, revokes the token, and restricts token-based publishing.
5. The project immediately publishes `0.1.0-alpha.2` through OIDC, verifies its
provenance and installation, and confirms that `next` resolves to
`0.1.0-alpha.2`.
Expand Down
3 changes: 2 additions & 1 deletion SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,4 +59,5 @@ environment, provenance, an immutable reviewed tag, and post-publication
installation verification. Long-lived registry tokens are forbidden. The sole
conditional bootstrap for the first alpha is documented in
[RELEASING.md](./RELEASING.md) and is owner-controlled, one-time, and immediately
revoked.
revoked. The workflow keeps this fallback disabled by default and rejects it
for every version other than `0.1.0-alpha.1`.
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@
"test:fixtures": "node scripts/test-fixtures.mjs",
"test:compat": "node scripts/test-compat.mjs",
"test:secrets": "node scripts/check-secrets.mjs",
"check:standalone-content": "node scripts/check-standalone-content.mjs",
"check:self-contained": "node scripts/check-self-contained.mjs",
"check:public-preview": "node scripts/check-public-preview.mjs",
"actionlint": "node scripts/run-actionlint.mjs",
Expand Down
17 changes: 16 additions & 1 deletion scripts/check-public-preview.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
collectPublicPreviewViolations,
formatPublicPreviewViolations,
} from "./release-validation.mjs";
import { collectStandaloneContentViolations } from "./standalone-content.mjs";

const inputViolations = [];
const read = (name) => {
Expand Down Expand Up @@ -47,7 +48,21 @@ const violations = collectPublicPreviewViolations({
sourceManifest,
});

const allViolations = [...inputViolations, ...violations];
const standaloneContentViolations = [];
try {
standaloneContentViolations.push(...collectStandaloneContentViolations(ROOT));
} catch (error) {
const detail = error instanceof Error ? error.message : String(error);
standaloneContentViolations.push(
`standalone content could not be checked: ${detail}`,
);
}

const allViolations = [
...inputViolations,
...violations,
...standaloneContentViolations,
];
if (allViolations.length > 0) {
console.error(formatPublicPreviewViolations(allViolations));
process.exitCode = 1;
Expand Down
141 changes: 8 additions & 133 deletions scripts/check-self-contained.mjs
Original file line number Diff line number Diff line change
@@ -1,152 +1,27 @@
import { isUtf8 } from "node:buffer";
import {
cpSync,
existsSync,
readFileSync,
readlinkSync,
readdirSync,
statSync,
} from "node:fs";
import {
basename,
dirname,
isAbsolute,
join,
relative,
resolve,
sep,
} from "node:path";
import { cpSync } from "node:fs";
import { basename, join } from "node:path";

import {
ROOT,
makeTemporaryDirectory,
removeTemporaryDirectory,
run,
} from "./lib.mjs";

const excluded = new Set([
".artifacts",
".cache",
".DS_Store",
".git",
"coverage",
"dist",
"node_modules",
]);
const parentReferencePattern =
/(?:^|[\s`"'(=:[{])((?:\.\.[\\/])+[A-Za-z0-9@%_+.,~\\/-]+)/gm;
const absoluteLocalPathPatterns = [
/(?:^|[\s`"'(=:[{])((?:file:\/\/\/(?:Users|home|private|root|Volumes|workspaces?)|\/(?:Users|home|private|root|Volumes|workspaces?))\/[A-Za-z0-9@%_+.,~/-]+)/gm,
/(?:^|[\s`"'(=:[{])((?:~\/|\$HOME\/|\$\{HOME\}\/)[A-Za-z0-9@%_+.,~/-]+)/gm,
/(?:^|[\s`"'(=:[{])([A-Za-z]:[\\/][^\s`"')\]}>;,]+)/gm,
/(?:^|[\s`"'(=:[{])(\\\\[A-Za-z0-9._-]+[\\/][^\s`"')\]}>;,]+)/gm,
];
const privateArtifactPattern = /\b(?:SDK_PRD\.md|references\/)/g;
const privateWorkspacePathPattern =
/\b(?:cometapi-worksapce|cometapi-(?:python|go|cli)|comet-api-(?:backend|frontend|next))[\\/][A-Za-z0-9@%_+.~/-]{2,}/g;
const privateReferencesDirectory = ["references", ""].join("/");
import {
collectStandaloneContentViolations,
STANDALONE_CONTENT_EXCLUSIONS,
} from "./standalone-content.mjs";

function shouldCopy(source) {
return !excluded.has(basename(source));
}

function isInside(root, path) {
const pathFromRoot = relative(root, path);
return (
pathFromRoot === "" ||
(!isAbsolute(pathFromRoot) &&
pathFromRoot !== ".." &&
!pathFromRoot.startsWith(`..${sep}`))
);
}

function scanTextFile(path, candidateRoot, violations) {
const bytes = readFileSync(path);
if (bytes.includes(0) || !isUtf8(bytes)) return;

const contents = bytes.toString("utf8");
const displayPath = relative(candidateRoot, path);

for (const match of contents.matchAll(parentReferencePattern)) {
const reference = match[1];
const resolvedReference = resolve(
dirname(path),
reference.replaceAll("\\", "/"),
);
if (!isInside(candidateRoot, resolvedReference)) {
violations.push(
`${displayPath}: parent-relative path escapes the repository (${reference})`,
);
}
}

for (const pattern of absoluteLocalPathPatterns) {
for (const match of contents.matchAll(pattern)) {
violations.push(
`${displayPath}: absolute machine-local path is not standalone (${match[1]})`,
);
}
}

for (const match of contents.matchAll(privateArtifactPattern)) {
const reference = match[0];
const rootEntry = reference.startsWith(privateReferencesDirectory)
? "references"
: reference;
if (!existsSync(join(candidateRoot, rootEntry))) {
violations.push(
`${displayPath}: references non-repository private material (${reference})`,
);
}
}

for (const match of contents.matchAll(privateWorkspacePathPattern)) {
const reference = match[0];
const rootEntry = reference.split(/[\\/]/, 1)[0];
if (!existsSync(join(candidateRoot, rootEntry))) {
violations.push(
`${displayPath}: references a private workspace or sibling repository (${reference})`,
);
}
}
}

function scan(directory, candidateRoot, violations) {
for (const entry of readdirSync(directory, { withFileTypes: true })) {
if (entry.isDirectory() && excluded.has(entry.name)) continue;
const path = join(directory, entry.name);
if (entry.isDirectory()) {
scan(path, candidateRoot, violations);
continue;
}
if (entry.isSymbolicLink()) {
const target = readlinkSync(path);
const resolvedTarget = resolve(dirname(path), target);
if (!isInside(candidateRoot, resolvedTarget)) {
violations.push(
`${relative(candidateRoot, path)}: symbolic link escapes the repository (${target})`,
);
continue;
}
if (!existsSync(resolvedTarget)) {
violations.push(
`${relative(candidateRoot, path)}: symbolic link target is missing (${target})`,
);
continue;
}
if (statSync(path).isDirectory()) continue;
}
scanTextFile(path, candidateRoot, violations);
}
return !STANDALONE_CONTENT_EXCLUSIONS.has(basename(source));
}

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

try {
cpSync(ROOT, candidateRoot, { filter: shouldCopy, recursive: true });
const violations = [];
scan(candidateRoot, candidateRoot, violations);
const violations = collectStandaloneContentViolations(candidateRoot);
if (violations.length > 0) {
throw new Error(
`Standalone repository scan found ${String(violations.length)} outside-root reference(s):\n- ${violations.join("\n- ")}`,
Expand Down
13 changes: 13 additions & 0 deletions scripts/check-standalone-content.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import { ROOT } from "./lib.mjs";
import {
collectStandaloneContentViolations,
formatStandaloneContentViolations,
} from "./standalone-content.mjs";

const violations = collectStandaloneContentViolations(ROOT);
if (violations.length > 0) {
console.error(formatStandaloneContentViolations(violations));
process.exitCode = 1;
} else {
console.log("Standalone content gate passed.");
}
Loading