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
8 changes: 1 addition & 7 deletions .github/workflows/deploey.yml
Original file line number Diff line number Diff line change
Expand Up @@ -40,12 +40,9 @@ jobs:
- name: Install dependencies
run: pnpm install --frozen-lockfile

- name: Prepare stable share IDs
- name: Verify deploy-time share ID generation
run: pnpm share-links:prepare

- name: Require generated IDs to be committed
run: git diff --exit-code -- .vitepress/data/share-links.json

- name: Check committed secrets
run: pnpm check:secrets

Expand Down Expand Up @@ -90,9 +87,6 @@ jobs:
- name: Build with VitePress
run: pnpm docs:build

- name: Verify stable registry remains clean
run: git diff --exit-code -- .vitepress/data/share-links.json

- name: Verify both share-link artifacts
run: pnpm share-links:artifacts:check

Expand Down
11 changes: 8 additions & 3 deletions .vitepress/scripts/prepare-share-links.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,11 @@ import path from "node:path";
import { prepareShareLinkFiles } from "../utilities/share-link-registry-files.ts";

const projectRoot = path.resolve(import.meta.dirname, "../..");
const registryFile = path.join(projectRoot, ".vitepress/data/share-links.json");
const baseRegistryFile = path.join(projectRoot, ".vitepress/data/share-links.json");
const generatedRegistryFile = path.join(
projectRoot,
".vitepress/generated/share-links.json",
);
const generatedManifestFile = path.join(
projectRoot,
".vitepress/generated/share-links-manifest.json",
Expand All @@ -14,11 +18,12 @@ const pageIds = await fg("posts/**/*.md", {
onlyFiles: true,
});
const result = await prepareShareLinkFiles({
registryFile,
baseRegistryFile,
generatedRegistryFile,
generatedManifestFile,
pageIds,
});

console.info(
`[share-links] ${result.added.length} added, ${result.unchangedCount} retained, ${result.registryChanged ? "registry updated" : "registry unchanged"}, ${result.manifestChanged ? "manifest updated" : "manifest unchanged"}`,
`[share-links] ${result.added.length} added, ${result.unchangedCount} retained, ${result.generatedRegistryChanged ? "generated registry updated" : "generated registry unchanged"}, ${result.manifestChanged ? "manifest updated" : "manifest unchanged"}`,
);
2 changes: 1 addition & 1 deletion .vitepress/scripts/publish-short-links.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import {
} from "../utilities/short-link-pages.ts";

const projectRoot = path.resolve(import.meta.dirname, "../..");
const registryFile = path.join(projectRoot, ".vitepress/data/share-links.json");
const registryFile = path.join(projectRoot, ".vitepress/generated/share-links.json");
const manifestFile = path.join(projectRoot, ".vitepress/generated/share-links-manifest.json");
const contentDistDir = path.join(projectRoot, ".vitepress/dist");
const outputDir = path.join(projectRoot, ".shortlink-dist");
Expand Down
21 changes: 11 additions & 10 deletions .vitepress/utilities/share-link-registry-files.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,8 @@ export {
} from "../shared/share-link-contract.ts";

export interface ShareLinkFilePaths {
registryFile: string;
baseRegistryFile: string;
generatedRegistryFile: string;
generatedManifestFile: string;
}

Expand All @@ -32,7 +33,7 @@ export interface PrepareShareLinkFilesInput extends ShareLinkFilePaths {
}

export interface PrepareShareLinkFilesResult extends PrepareShareLinksResult {
registryChanged: boolean;
generatedRegistryChanged: boolean;
manifestChanged: boolean;
manifest: ShareLinkManifest;
}
Expand Down Expand Up @@ -192,14 +193,14 @@ function createManifest(registry: ShareLinkRegistry, registryHash: string): Shar
export async function prepareShareLinkFiles(
input: PrepareShareLinkFilesInput,
): Promise<PrepareShareLinkFilesResult> {
const releaseLock = await acquireRegistryLock(input.registryFile);
const releaseLock = await acquireRegistryLock(input.generatedRegistryFile);

try {
const readText = input.readTextIfExists ?? readTextIfExists;
const initialRegistryContent = await readText(input.registryFile);
const initialRegistryContent = await readText(input.baseRegistryFile);
const initialRegistryHash = hashContent(initialRegistryContent ?? "");
const existingRegistry = await loadShareLinkRegistryFromReader(
input.registryFile,
input.baseRegistryFile,
readText,
);
const prepared = prepareShareLinks({
Expand All @@ -208,16 +209,16 @@ export async function prepareShareLinkFiles(
});
const registryContent = serializeJson(prepared.registry);

const beforeWriteRegistryContent = await readText(input.registryFile);
const beforeWriteRegistryContent = await readText(input.baseRegistryFile);

if (hashContent(beforeWriteRegistryContent ?? "") !== initialRegistryHash) {
throw new Error(
`分享注册表在 prepare 期间已被修改,拒绝覆盖:${input.registryFile}`,
`基础分享注册表在 prepare 期间已被修改,拒绝生成:${input.baseRegistryFile}`,
);
}

const registryChanged = await writeTextAtomicallyIfChanged(
input.registryFile,
const generatedRegistryChanged = await writeTextAtomicallyIfChanged(
input.generatedRegistryFile,
registryContent,
);
const manifest = createManifest(
Expand All @@ -231,7 +232,7 @@ export async function prepareShareLinkFiles(

return {
...prepared,
registryChanged,
generatedRegistryChanged,
manifestChanged,
manifest,
};
Expand Down
139 changes: 100 additions & 39 deletions tests/unit/share-link-registry-files.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,8 @@ import {
async function withTemporaryFiles(
callback: (input: {
root: string;
registryFile: string;
baseRegistryFile: string;
generatedRegistryFile: string;
manifestFile: string;
}) => Promise<void>,
): Promise<void> {
Expand All @@ -23,7 +24,8 @@ async function withTemporaryFiles(
try {
await callback({
root,
registryFile: path.join(root, "data/share-links.json"),
baseRegistryFile: path.join(root, "data/share-links.json"),
generatedRegistryFile: path.join(root, "generated/share-links.json"),
manifestFile: path.join(root, "generated/share-links-manifest.json"),
});
} finally {
Expand All @@ -35,95 +37,143 @@ function hash(value: string): string {
return createHash("sha256").update(value, "utf8").digest("hex");
}

test("prepare writes only missing ids and a query manifest", async () => {
await withTemporaryFiles(async ({ registryFile, manifestFile }) => {
test("prepare derives missing ids without modifying the base registry", async () => {
await withTemporaryFiles(async ({
baseRegistryFile,
generatedRegistryFile,
manifestFile,
}) => {
const pageIds = ["posts/B.md", "posts/中文.md", "posts/A.md"];
const baseRegistryContent = `${JSON.stringify(
{
version: 1,
records: {
k7m2p9x4qd: { pageId: "posts/A.md", status: "active" },
},
},
null,
2,
)}\n`;
await fs.mkdir(path.dirname(baseRegistryFile), { recursive: true });
await fs.writeFile(baseRegistryFile, baseRegistryContent, "utf8");

const first = await prepareShareLinkFiles({
registryFile,
baseRegistryFile,
generatedRegistryFile,
generatedManifestFile: manifestFile,
pageIds,
});
const registry = await loadShareLinkRegistry(registryFile);
const registry = await loadShareLinkRegistry(generatedRegistryFile);
const manifest = JSON.parse(await fs.readFile(manifestFile, "utf8")) as {
byCanonicalPath: Record<string, string>;
shortOrigin: string;
};

assert.equal(first.added.length, 3);
assert.equal(first.registryChanged, true);
assert.equal(first.added.length, 2);
assert.equal(first.unchangedCount, 1);
assert.equal(first.generatedRegistryChanged, true);
assert.equal(first.manifestChanged, true);
assert.equal(Object.keys(registry.records).length, 3);
assert.equal(Object.keys(manifest.byCanonicalPath).length, 3);
assert.equal(manifest.shortOrigin, "https://yuufrag.machillka.com");
assert.ok(manifest.byCanonicalPath["/posts/中文"]);
assert.equal(registry.records.k7m2p9x4qd.pageId, "posts/A.md");
assert.equal(await fs.readFile(baseRegistryFile, "utf8"), baseRegistryContent);
});
});

test("repeated prepare is stable and does not rewrite unchanged files", async () => {
await withTemporaryFiles(async ({ registryFile, manifestFile }) => {
await withTemporaryFiles(async ({
baseRegistryFile,
generatedRegistryFile,
manifestFile,
}) => {
const pageIds = ["posts/A.md"];
await prepareShareLinkFiles({
registryFile,
baseRegistryFile,
generatedRegistryFile,
generatedManifestFile: manifestFile,
pageIds,
});
const registryBefore = await fs.readFile(registryFile, "utf8");
const registryBefore = await fs.readFile(generatedRegistryFile, "utf8");
const manifestBefore = await fs.readFile(manifestFile, "utf8");
const second = await prepareShareLinkFiles({
registryFile,
baseRegistryFile,
generatedRegistryFile,
generatedManifestFile: manifestFile,
pageIds,
});

assert.equal(second.added.length, 0);
assert.equal(second.registryChanged, false);
assert.equal(second.generatedRegistryChanged, false);
assert.equal(second.manifestChanged, false);
assert.equal(await fs.readFile(registryFile, "utf8"), registryBefore);
assert.equal(await fs.readFile(generatedRegistryFile, "utf8"), registryBefore);
assert.equal(await fs.readFile(manifestFile, "utf8"), manifestBefore);
});
});

test("check is read-only", async () => {
await withTemporaryFiles(async ({ registryFile, manifestFile }) => {
await withTemporaryFiles(async ({
baseRegistryFile,
generatedRegistryFile,
manifestFile,
}) => {
const pageIds = ["posts/A.md"];
await prepareShareLinkFiles({
registryFile,
baseRegistryFile,
generatedRegistryFile,
generatedManifestFile: manifestFile,
pageIds,
});
const before = await fs.readFile(registryFile, "utf8");
const before = await fs.readFile(generatedRegistryFile, "utf8");

const result = await checkShareLinkFiles({ registryFile, pageIds });
const result = await checkShareLinkFiles({
registryFile: generatedRegistryFile,
pageIds,
});

assert.equal(result.activeCount, 1);
assert.equal(result.goneCount, 0);
assert.equal(await fs.readFile(registryFile, "utf8"), before);
assert.equal(await fs.readFile(generatedRegistryFile, "utf8"), before);
});
});

test("prepare refuses to run while the registry lock exists", async () => {
await withTemporaryFiles(async ({ registryFile, manifestFile }) => {
await fs.mkdir(path.dirname(registryFile), { recursive: true });
await fs.writeFile(`${registryFile}.lock`, "other-process\n", "utf8");
await withTemporaryFiles(async ({
baseRegistryFile,
generatedRegistryFile,
manifestFile,
}) => {
await fs.mkdir(path.dirname(generatedRegistryFile), { recursive: true });
await fs.writeFile(
`${generatedRegistryFile}.lock`,
"other-process\n",
"utf8",
);

await assert.rejects(
() =>
prepareShareLinkFiles({
registryFile,
baseRegistryFile,
generatedRegistryFile,
generatedManifestFile: manifestFile,
pageIds: ["posts/A.md"],
}),
/分享注册表正被另一个进程更新/,
);
await assert.rejects(() => fs.access(registryFile));
await assert.rejects(() => fs.access(baseRegistryFile));
await assert.rejects(() => fs.access(generatedRegistryFile));
});
});

test("prepare refuses to replace a registry changed outside its lock", async () => {
await withTemporaryFiles(async ({ registryFile, manifestFile }) => {
await fs.mkdir(path.dirname(registryFile), { recursive: true });
await withTemporaryFiles(async ({
baseRegistryFile,
generatedRegistryFile,
manifestFile,
}) => {
await fs.mkdir(path.dirname(baseRegistryFile), { recursive: true });
await fs.writeFile(
registryFile,
baseRegistryFile,
JSON.stringify({ version: 1, records: {} }),
"utf8",
);
Expand All @@ -138,10 +188,10 @@ test("prepare refuses to replace a registry changed outside its lock", async ()
let registryReadCount = 0;

const readTextIfExists = async (file: string): Promise<string | undefined> => {
if (file === registryFile) {
if (file === baseRegistryFile) {
registryReadCount += 1;
if (registryReadCount === 3) {
await fs.writeFile(registryFile, changedContent, "utf8");
await fs.writeFile(baseRegistryFile, changedContent, "utf8");
}
}

Expand All @@ -159,22 +209,31 @@ test("prepare refuses to replace a registry changed outside its lock", async ()
await assert.rejects(
() =>
prepareShareLinkFiles({
registryFile,
baseRegistryFile,
generatedRegistryFile,
generatedManifestFile: manifestFile,
pageIds: ["posts/A.md"],
readTextIfExists,
}),
/分享注册表在 prepare 期间已被修改/,
/基础分享注册表在 prepare 期间已被修改/,
);
assert.equal(
hash(await fs.readFile(baseRegistryFile, "utf8")),
hash(changedContent),
);
assert.equal(hash(await fs.readFile(registryFile, "utf8")), hash(changedContent));
await assert.rejects(() => fs.access(generatedRegistryFile));
});
});

test("active records that no longer point to a page fail before writing", async () => {
await withTemporaryFiles(async ({ registryFile, manifestFile }) => {
await fs.mkdir(path.dirname(registryFile), { recursive: true });
await withTemporaryFiles(async ({
baseRegistryFile,
generatedRegistryFile,
manifestFile,
}) => {
await fs.mkdir(path.dirname(baseRegistryFile), { recursive: true });
await fs.writeFile(
registryFile,
baseRegistryFile,
JSON.stringify({
version: 1,
records: {
Expand All @@ -183,17 +242,19 @@ test("active records that no longer point to a page fail before writing", async
}),
"utf8",
);
const before = await fs.readFile(registryFile, "utf8");
const before = await fs.readFile(baseRegistryFile, "utf8");

await assert.rejects(
() =>
prepareShareLinkFiles({
registryFile,
baseRegistryFile,
generatedRegistryFile,
generatedManifestFile: manifestFile,
pageIds: ["posts/新页面.md"],
}),
/active 分享 ID 指向不存在页面:k7m2p9x4qd -> posts\/旧页面\.md/,
);
assert.equal(await fs.readFile(registryFile, "utf8"), before);
assert.equal(await fs.readFile(baseRegistryFile, "utf8"), before);
await assert.rejects(() => fs.access(generatedRegistryFile));
});
});
Loading