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
5 changes: 5 additions & 0 deletions .changeset/public-install-guidance.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@inkcre/ui-web": patch
---

修正安装指南中的 GitHub Packages 认证链接。
12 changes: 9 additions & 3 deletions .github/workflows/release-and-publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -45,14 +45,20 @@ jobs:
uses: changesets/action@a45c4d594aa4e2c509dc14a9f2b3b67ba3780d0d # v1.9.0
with:
version: pnpm changeset:version
publish: pnpm run release
commit: "chore: version packages"
title: "chore: version packages"
publish: pnpm changeset:publish
commit: "chore(release): 更新包版本"
title: "chore(release): 更新包版本"
# The action reads this input (not the GITHUB_TOKEN env) for PR creation.
github-token: ${{ steps.release-app-token.outputs.token }}
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Publish anonymous package download
if: steps.changesets.outputs.hasChangesets == 'false'
run: pnpm release:public "$(node -p "require('./packages/web/package.json').version")" --publish
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Complete SwiftPM tag and Logo release attachment
if: steps.changesets.outputs.hasChangesets == 'false'
run: python3 scripts/logo-release.py publish
Expand Down
40 changes: 39 additions & 1 deletion CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -124,14 +124,52 @@ registry artifact. The consumer's
[`docs/40-deployment/development-runtime.md`](https://github.com/InKCre/impl-ts/blob/main/docs/40-deployment/development-runtime.md#joint-development-lanes)
owns the full startup, remotes, cleanup, troubleshooting, and release-fidelity contract.

## Public package delivery

`@inkcre/ui-web` has no confidentiality requirement. GitHub Packages remains the publishing
registry; anonymous consumers use the original package tarball attached to its versioned GitHub
Release. Changing GitHub Package visibility alone does not remove npm authentication.

The release workflow publishes with Changesets, then runs `pnpm release:public <version> --publish`.
The command reads the already-published registry tarball, checks its integrity and package identity,
and attaches those exact bytes to the existing `@inkcre/ui-web@<version>` Release. It does not build,
version, or publish an npm package. An identical attachment is a successful no-op; a conflicting
attachment fails without overwriting it. Anonymous download and byte comparison complete the check.

Maintainers can prepare or recover a single version independently of npm publication:

```bash
pnpm release:public 2.3.0 # Read-only checks; does not upload
pnpm release:public 2.3.0 --publish # Explicit public upload or verification
```

This requires GitHub CLI authentication for release access and npm authentication for package reads;
upload additionally requires repository contents write access. No token is embedded in the download
URL. Run `pnpm check:public-package` for the append-only/retry regression check.

Consumers retain semver dependencies where runtime sharing reads those ranges. After the asset is
published and anonymously verified, a pnpm workspace can route the exact version using a root
`pnpm-workspace.yaml` override:

```yaml
overrides:
'@inkcre/ui-web@2.3.0': https://github.com/InKCre/ui/releases/download/%40inkcre%2Fui-web%402.3.0/inkcre-ui-web-2.3.0.tgz
```

Regenerate and review the lockfile, then verify a fresh `pnpm install --frozen-lockfile` with an empty
store and no GitHub token, plus consumer builds. Never activate an override before its attachment
exists. The initial 2.3.0 attachment and `impl-ts` consumer cutover are pending release approval;
the current consumer lockfile still requires GitHub Packages access.

## GitHub Packages authentication

The committed `.npmrc` routes only the `@inkcre` scope to GitHub Packages. It never
stores or expands a credential. Local credentials belong to the trusted user
configuration.

For interactive one-time setup, create a classic GitHub personal access token with
`read:packages`, then let npm write it to your user-level configuration:
`read:packages` and confirm read access to the package, then let npm write it to your user-level
configuration. A token scope does not grant the package ACL:

```bash
npm login --scope=@inkcre --auth-type=legacy --registry=https://npm.pkg.github.com
Expand Down
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,10 @@ Web 消费者从 `@inkcre/ui-web/logo/*` 导入,Swift 消费者依赖本仓库

### Use the package

The current GitHub Packages path requires package read access and npm authentication. A public
Release-tarball path is being prepared; see the [delivery and cutover contract](CONTRIBUTING.md#public-package-delivery)
before selecting an installation path.

```bash
pnpm add @inkcre/ui-web
```
Expand Down
4 changes: 3 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@
"check:generated": "tsx scripts/check-generated.ts",
"check:stories": "tsx scripts/check-story-coverage.ts",
"check:package": "tsx scripts/check-package-contract.ts",
"check:public-package": "tsx --test scripts/test-public-package.ts",
"release:public": "tsx scripts/publish-public-package.ts",
"check:release": "tsx scripts/version-package.ts --check && tsx --test scripts/test-version-package.ts",
"check:token-workflow": "tsx scripts/prepare-token-update.ts fixture",
"tokens:update": "tsx scripts/prepare-token-update.ts apply",
Expand All @@ -24,7 +26,7 @@
"format": "oxfmt .oxfmtrc.json package.json pnpm-workspace.yaml tsconfig.json scripts .github/workflows/*.yml README.md DESIGN.md docs/design && pnpm --filter @inkcre/ui-web format",
"format:check": "oxfmt --check .oxfmtrc.json package.json pnpm-workspace.yaml tsconfig.json scripts .github/workflows/*.yml README.md DESIGN.md docs/design && pnpm --filter @inkcre/ui-web format:check",
"lint": "oxlint --deny-warnings scripts && pnpm --filter @inkcre/ui-web lint",
"check": "pnpm format:check && pnpm lint && pnpm check:generated && pnpm --filter @inkcre/ui-web skill:validate && pnpm check:stories && pnpm check:token-workflow && pnpm check:release && pnpm check:logo && pnpm type-check:root && pnpm build && pnpm check:package && pnpm story",
"check": "pnpm format:check && pnpm lint && pnpm check:generated && pnpm --filter @inkcre/ui-web skill:validate && pnpm check:stories && pnpm check:token-workflow && pnpm check:release && pnpm check:logo && pnpm check:public-package && pnpm type-check:root && pnpm build && pnpm check:package && pnpm story",
"changeset": "changeset",
"changeset:add": "changeset add",
"changeset:version": "tsx scripts/version-package.ts",
Expand Down
105 changes: 105 additions & 0 deletions scripts/publish-public-package.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
import assert from "node:assert/strict";
import { execFileSync } from "node:child_process";
import { createHash } from "node:crypto";
import { mkdtempSync, readFileSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join, resolve } from "node:path";
import { pathToFileURL } from "node:url";

const repository = "InKCre/ui";
const packageName = "@inkcre/ui-web";

export function ensureAsset(
original: Buffer,
existing: Buffer | undefined,
upload: () => void,
): void {
if (existing !== undefined) {
assert.ok(
existing.equals(original),
"Existing release asset differs; refusing to overwrite it",
);
return;
}
upload();
}

async function download(url: string): Promise<Buffer> {
// This intentionally sends no GitHub credential: consumers must be able to do the same.
const response = await fetch(url, { signal: AbortSignal.timeout(60_000) });
assert.ok(response.ok, `Anonymous download failed: ${response.status} ${url}`);
return Buffer.from(await response.arrayBuffer());
}

async function main(): Promise<void> {
const [version, mode] = process.argv.slice(2);
assert.ok(
version && /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/.test(version),
"Usage: pnpm release:public <exact-version> [--publish] (default: read-only preparation)",
);
assert.ok(mode === undefined || mode === "--publish", "Only --publish is supported");
assert.ok(process.argv.length <= 4, "Unexpected arguments");
const tag = `${packageName}@${version}`;
const filename = `inkcre-ui-web-${version}.tgz`;
const url = `https://github.com/${repository}/releases/download/${encodeURIComponent(tag)}/${filename}`;
const release = JSON.parse(
execFileSync("gh", ["release", "view", tag, "--repo", repository, "--json", "isDraft,assets"], {
encoding: "utf8",
}),
) as { isDraft: boolean; assets: { name: string; url: string }[] };
assert.ok(!release.isDraft, "The corresponding published GitHub Release must already exist");

const directory = mkdtempSync(join(tmpdir(), "inkcre-public-package-"));
try {
const registry = "--registry=https://npm.pkg.github.com";
const integrity = JSON.parse(
execFileSync("npm", ["view", tag, "dist.integrity", "--json", registry], {
encoding: "utf8",
}),
) as string;
execFileSync(
"npm",
["pack", tag, "--ignore-scripts", "--pack-destination", directory, "--json", registry],
{ stdio: ["ignore", "pipe", "inherit"] },
);
const tarball = join(directory, filename);
const original = readFileSync(tarball);
assert.equal(`sha512-${createHash("sha512").update(original).digest("base64")}`, integrity);
const manifest = JSON.parse(
execFileSync("tar", ["-xOf", tarball, "package/package.json"], { encoding: "utf8" }),
) as { name: string; version: string };
assert.equal(manifest.name, packageName);
assert.equal(manifest.version, version);

const asset = release.assets.find((item) => item.name === filename);
const existing = asset ? await download(url) : undefined;
ensureAsset(original, existing, () => {
if (mode !== "--publish") return;
// Never clobber: concurrent or conflicting publication must stop for inspection.
execFileSync("gh", ["release", "upload", tag, tarball, "--repo", repository], {
stdio: "inherit",
});
});
if (mode === "--publish") {
assert.ok(
(await download(url)).equals(original),
"Public asset does not match registry bytes",
);
}
console.log(
JSON.stringify({
version,
integrity,
url,
published: mode === "--publish",
existing: !!asset,
}),
);
} finally {
rmSync(directory, { recursive: true, force: true });
}
}

if (process.argv[1] && pathToFileURL(resolve(process.argv[1])).href === import.meta.url) {
await main();
}
27 changes: 27 additions & 0 deletions scripts/test-public-package.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import assert from "node:assert/strict";
import { test } from "node:test";
import { ensureAsset } from "./publish-public-package.js";

test("release assets are append-only, repeatable, and preserve upload failures", () => {
const original = Buffer.from("published registry bytes");
let uploads = 0;
const upload = () => {
uploads += 1;
};
ensureAsset(original, undefined, upload);
assert.equal(uploads, 1);
ensureAsset(original, Buffer.from(original), upload);
assert.equal(uploads, 1);
assert.throws(
() => ensureAsset(original, Buffer.from("different build"), upload),
/refusing to overwrite/,
);
assert.equal(uploads, 1);
assert.throws(
() =>
ensureAsset(original, undefined, () => {
throw new Error("upload failed");
}),
/upload failed/,
);
});
Loading